-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrun.sh
More file actions
executable file
·521 lines (474 loc) · 19.5 KB
/
Copy pathrun.sh
File metadata and controls
executable file
·521 lines (474 loc) · 19.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
#!/usr/bin/env bash
#
# Task runner for a Vaadin and Spring Boot project, built with Maven.
#
# Every task pins JAVA_HOME to the JDK the project is built for, because a bare
# `mvn` picks whatever the machine defaults to — and under a newer JDK than Lombok
# supports it silently generates no getters, so the build dies with bogus
# "cannot find symbol" errors.
#
# Everything specific to one project is in run.conf beside it; nothing in this file
# names a project, so projects share it as it stands. A task this runner does not
# have goes in run.tasks.sh, for the same reason.
#
# Usage: ./run.sh <task> (or `help` to list tasks).
set -euo pipefail
cd "$(dirname "$0")"
readonly PROJECT_CONFIG_FILE="run.conf"
# Tasks a project has that this runner does not: a file of task_<name> functions,
# sourced below the helpers so they can call setup and run_mvn like the tasks here
# do. Keeping them out of this file is what lets a project take a newer runner
# without a merge. It may also define project_usage, printed under the task list.
readonly PROJECT_TASKS_FILE="run.tasks.sh"
if [[ ! -f "${PROJECT_CONFIG_FILE}" ]]; then
echo "No ${PROJECT_CONFIG_FILE} found" >&2
exit 1
fi
source "./${PROJECT_CONFIG_FILE}"
# A project has to answer these two; nothing sensible can be assumed for either.
for setting in PROJECT_NAME JAVA_VERSION; do
if [[ -z "${!setting:-}" ]]; then
echo "${PROJECT_CONFIG_FILE} sets no ${setting}" >&2
exit 1
fi
done
unset setting
# Everything else falls back to the layout a Vaadin project has by default, so a
# configuration only names what it decides. See run.conf for what each one is.
: "${ENV_DIR:=environment/dev}"
: "${CONTAINER_REQUIRED:=true}"
: "${CONTAINER_PREFIX:=${PROJECT_NAME,,}-dev}"
: "${BUNDLE_DIR:=src/main/bundles}"
: "${STYLES_CSS:=src/main/resources/META-INF/resources/styles.css}"
: "${IT_PROFILE:=}"
: "${COMMIT_PROPERTY:=}"
readonly PROJECT_NAME JAVA_VERSION ENV_DIR CONTAINER_REQUIRED CONTAINER_PREFIX
readonly BUNDLE_DIR STYLES_CSS IT_PROFILE COMMIT_PROPERTY
readonly DEV_COMPOSE_FILE="${ENV_DIR}/compose.yaml"
readonly QUADLET_DIR="${ENV_DIR}/quadlet"
readonly QUADLET_INSTALL_DIR="${XDG_CONFIG_HOME:-${HOME}/.config}/containers/systemd"
# Filled in by setup, and the only mvn any task runs.
MAVEN=""
# Resolve the configured JDK from SDKMAN; fall back to whatever JAVA_HOME is set.
resolve_java_home() {
local jdk
jdk=$(ls -d "${HOME}"/.sdkman/candidates/java/"${JAVA_VERSION}"* 2>/dev/null | head -1 || true)
if [[ -n "${jdk}" ]]; then
export JAVA_HOME="${jdk}"
elif [[ -z "${JAVA_HOME:-}" ]]; then
echo "No JDK ${JAVA_VERSION} found under ~/.sdkman and JAVA_HOME is unset." >&2
exit 1
fi
echo "Using JAVA_HOME=${JAVA_HOME}"
}
# Which runtime this machine has, or nothing at all. docker first: where both are
# installed, docker is the one whose context and compose plugin this script drives,
# and podman is usually there as its backend.
container_runtime() {
if command -v docker >/dev/null 2>&1; then
echo "docker"
elif command -v podman >/dev/null 2>&1; then
echo "podman"
fi
}
require_container_runtime() {
local runtime
runtime=$(container_runtime)
if [[ -z "${runtime}" ]]; then
echo "No container runtime found. Either docker or podman is required." >&2
exit 1
fi
echo "${runtime}"
}
# Testcontainers finds the daemon through DOCKER_HOST or /var/run/docker.sock and
# looks nowhere else. Neither CLI advertises itself there: docker keeps its endpoint
# in a "context", so on Colima and Rancher Desktop the CLI works while Testcontainers
# reports "Could not find a valid Docker environment", and rootless podman puts its
# socket under /run/user/<uid>. So ask whichever CLI this machine has where its
# socket is, and hand that to Testcontainers.
#
# The override is for Ryuk, the cleanup sidecar, which mounts the socket from inside
# its own container and so needs a path rather than an endpoint. The two runtimes
# disagree on which path: a VM-backed docker sees /var/run/docker.sock however the
# host spells it, while rootless podman runs no VM and needs the real path.
#
# Starting the socket is deliberately left to whoever runs this — the task says what
# to run and stops rather than enabling a system service behind their back.
resolve_container_runtime() {
if [[ -n "${DOCKER_HOST:-}" ]]; then
return
fi
if [[ -S /var/run/docker.sock ]]; then
return
fi
local runtime endpoint="" override=""
runtime=$(require_container_runtime)
case "${runtime}" in
docker)
endpoint=$(docker context inspect --format '{{.Endpoints.docker.Host}}' 2>/dev/null || true)
override="/var/run/docker.sock"
;;
podman)
local socket
socket=$(podman info --format '{{.Host.RemoteSocket.Path}}' 2>/dev/null || true)
if [[ -n "${socket}" ]]; then
endpoint="unix://${socket}"
override="${socket}"
fi
;;
esac
if [[ -z "${endpoint}" ]]; then
echo "${runtime} is installed but would not say where its socket is." >&2
echo "Set DOCKER_HOST by hand, or check that ${runtime} itself works." >&2
exit 1
fi
# podman reports its socket path whether or not anything is listening on it, and
# a docker context outlives the daemon it points at, so neither answer proves the
# runtime is up. Say which command starts it instead of failing deep inside a test.
if [[ "${endpoint}" == unix://* && ! -S "${endpoint#unix://}" ]]; then
echo "${runtime} reports its socket at ${endpoint#unix://}, but nothing is listening." >&2
if [[ "${runtime}" == "podman" ]]; then
echo "Start it with: systemctl --user start podman.socket" >&2
else
echo "Start the docker daemon, then retry." >&2
fi
exit 1
fi
export DOCKER_HOST="${endpoint}"
export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE="${TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE:-${override}}"
echo "Using ${runtime} at DOCKER_HOST=${DOCKER_HOST}"
}
# The mvn to run. A bare `mvn` is on the PATH of a login shell only, because SDKMAN
# installs it under ~/.sdkman and the profile is what puts it there — so anything
# starting this script without one, the Claude Code preview pane included, dies with
# "mvn: command not found". Resolve it the way JAVA_HOME is resolved, rather than
# depending on how this script happened to be started.
resolve_maven() {
if command -v mvn >/dev/null 2>&1; then
MAVEN="mvn"
echo "Using MAVEN=${MAVEN} (from the PATH)"
return
fi
local candidate
candidate=$(ls -d "${HOME}"/.sdkman/candidates/maven/current/bin/mvn \
"${HOME}"/.sdkman/candidates/maven/*/bin/mvn 2>/dev/null | head -1 || true)
if [[ -z "${candidate}" ]]; then
echo "No mvn on the PATH and none under ~/.sdkman." >&2
echo "Install Maven, or start this from a shell where SDKMAN is initialised." >&2
exit 1
fi
MAVEN="${candidate}"
echo "Using MAVEN=${MAVEN}"
}
# What every task needs before it can do anything: the pinned JDK, the mvn that goes
# with it, and the container runtime. Resolved together, in one place, so that a task
# body is the work it is named after and nothing else.
#
# The runtime is not only the test suite's concern, which is why a project that needs
# one resolves it here rather than in the tasks that obviously want it: the application
# itself will not start without the services it talks to, and `package` runs the tests
# on the way through. A machine missing any of it is told so before the work starts
# rather than partway into it.
#
# No exceptions among the tasks either, not even the ones that never reach for a
# container: touching a stylesheet on a machine that cannot run the application
# accomplishes nothing, and failing at the first task is what says so. CONTAINER_REQUIRED
# is the one switch, and it is the project's answer rather than the task's.
setup() {
resolve_java_home
resolve_maven
if [[ "${CONTAINER_REQUIRED}" == "true" ]]; then
resolve_container_runtime
fi
}
# Every build carries the deployed commit SHA when the project asks for one, so an
# artefact can be traced back to what produced it. Resolved from the working tree and
# passed to every mvn invocation. A non-repo checkout is then a hard error by design:
# an unidentifiable build should never be produced.
run_mvn() {
local -a properties=()
if [[ -n "${COMMIT_PROPERTY}" ]]; then
local commit
if ! commit=$(git rev-parse HEAD 2>/dev/null); then
echo "Cannot resolve the git commit (not a git repository?)." >&2
echo "${COMMIT_PROPERTY} is mandatory; refusing to build." >&2
exit 1
fi
properties+=("-D${COMMIT_PROPERTY}=${commit}")
fi
"${MAVEN}" "$@" "${properties[@]}"
}
# Bump styles.css mtime. styles.css is only @import statements; editing an
# imported partial (colors.css, grid.css, ...) leaves styles.css unchanged, so
# the browser serves a 304 and keeps the stale partial. Touching the entry file
# busts that cache.
touch_styles() {
touch "${STYLES_CSS}"
echo "Touched ${STYLES_CSS}"
}
# Remove the cached frontend bundles. Vaadin's dev server reuses an existing
# bundle and logs "a development mode bundle build is not needed", so a changed
# @CssImport(themeFor=...) is ignored until both bundles are gone. Deleting only
# dev.bundle is not enough — a leftover prod.bundle is still treated as valid.
clear_bundles() {
rm -rf "${BUNDLE_DIR}/dev.bundle" "${BUNDLE_DIR}/prod.bundle" target/dev-bundle
echo "Cleared cached frontend bundles"
}
task_compile() {
setup
run_mvn -q compile
echo "Compiled. spring-boot-devtools will hot-restart a running app."
}
# Force a clean frontend rebuild after a @CssImport / @JsModule change: drop the
# cached bundles, touch styles.css, then compile so devtools rebuilds the bundle.
task_bundle() {
setup
clear_bundles
touch_styles
run_mvn -q compile
echo "Bundle cleared and recompiled. Reload the browser to pick up the new bundle."
}
task_styles() {
setup
touch_styles
run_mvn -q process-resources
echo "Stylesheets processed. Reload the browser."
}
# Docker renamed compose from a script to a subcommand and both are still in the
# wild, so find whichever this machine has rather than guessing. Only the docker
# shapes: podman drives the stack through quadlets instead.
compose() {
if docker compose version >/dev/null 2>&1; then
docker compose -f "${DEV_COMPOSE_FILE}" "$@"
elif command -v docker-compose >/dev/null 2>&1; then
docker-compose -f "${DEV_COMPOSE_FILE}" "$@"
else
echo "No Compose found. Install the Docker Compose plugin (docker compose)" >&2
echo "or the standalone docker-compose, then retry." >&2
exit 1
fi
}
# The units of the development stack, derived from what is in the quadlet directory
# so that adding a service there needs no change here — the same property the compose
# file has.
quadlet_services() {
local unit
for unit in "${QUADLET_DIR}"/*.container.in; do
echo "$(basename "${unit}" .container.in).service"
done
}
# Copy the units into the directory systemd's generator reads, substituting the one
# thing a quadlet cannot state for itself: where this checkout lives, which the two
# files mounted into Keycloak need as an absolute path.
install_quadlets() {
mkdir -p "${QUADLET_INSTALL_DIR}"
local unit
for unit in "${QUADLET_DIR}"/*.in; do
sed "s|@ENV_DIR@|${PWD}/${ENV_DIR}|g" \
"${unit}" > "${QUADLET_INSTALL_DIR}/$(basename "${unit}" .in)"
done
systemctl --user daemon-reload
echo "Installed quadlet units into ${QUADLET_INSTALL_DIR}"
}
# podman drives the stack through quadlets rather than compose: systemd starts the
# units, orders them, and — because every container declares Notify=healthy — reports
# one as started only once its healthcheck passes. That is what `up --wait` and
# `depends_on: service_healthy` bought under compose, so nothing is lost by not
# having a compose file in the loop.
quadlet_env() {
local action="$1"
local -a services
mapfile -t services < <(quadlet_services)
case "${action}" in
up)
install_quadlets
systemctl --user start "${services[@]}"
echo "Development stack is up. ${PROJECT_NAME} needs no configuration to reach it."
echo "It is started, not enabled: add 'systemctl --user enable' yourself to"
echo "get it back after a reboot."
;;
down)
systemctl --user stop "${services[@]}"
echo "Development stack stopped; its data is kept."
;;
logs)
local -a follow=()
local service
for service in "${services[@]}"; do
follow+=(--unit "${service}")
done
journalctl --user --follow "${follow[@]}"
;;
reset)
systemctl --user stop "${services[@]}"
# Every volume of the stack rather than a named one, for the same reason
# the services are read from a directory: this should not need editing
# when the stack gains storage.
podman volume ls --filter name="${CONTAINER_PREFIX}" --quiet \
| xargs --no-run-if-empty podman volume rm --force >/dev/null
echo "Development stack stopped and its data thrown away."
;;
esac
}
compose_env() {
local action="$1"
case "${action}" in
# --wait rather than plain -d: every service declares a healthcheck, and the
# app starting before them is what turns a slow container into a confusing
# connection or discovery error at startup.
up) compose up -d --wait && echo "Development stack is up. ${PROJECT_NAME} needs no configuration to reach it." ;;
down) compose stop && echo "Development stack stopped; its data is kept." ;;
logs) compose logs -f ;;
reset) compose down -v && echo "Development stack stopped and its data thrown away." ;;
esac
}
# The whole development stack in one task, because an application that needs a
# database, a browser and an identity provider needs all of them.
#
# Which containers that means is never this function's decision — compose reads the
# stack's compose.yaml, quadlets come from its quadlet directory — so adding a
# service needs no change here.
task_env() {
setup
if [[ "${CONTAINER_REQUIRED}" != "true" ]]; then
echo "CONTAINER_REQUIRED is ${CONTAINER_REQUIRED}: this project is configured to need" >&2
echo "no containers, so there is no development stack for this task to bring up." >&2
exit 1
fi
local action="${1:-up}"
case "${action}" in
up|down|logs|reset) ;;
*)
echo "Unknown env action: ${action} (expected up, down, logs or reset)" >&2
exit 1
;;
esac
local runtime
runtime=$(require_container_runtime)
if [[ "${runtime}" == "podman" ]]; then
quadlet_env "${action}"
else
compose_env "${action}"
fi
}
# Warm the local Maven repository ahead of a build, so a later task needs no
# network. dependency:resolve would walk the dependency tree alone and leave the
# build plugins out; go-offline takes both.
#
# It cannot be exhaustive: surefire picks its provider (surefire-junit-platform)
# off the test classpath as it runs, and no resolution goal sees that in advance.
# The first `test` downloads it. That is a slow first run, not a broken one.
task_deps() {
setup
run_mvn --batch-mode --no-transfer-progress dependency:go-offline
}
task_test() {
setup
# The pom's JaCoCo gate runs with these, and fails the task under its threshold.
run_mvn test "$@"
}
task_run() {
setup
run_mvn spring-boot:run
}
# Same as `run`, but named for the Claude Code preview pane, which launches the
# app through this task (see .claude/launch.json) so the preview goes through
# run.sh — the pinned JDK and the bundle gotchas — like every other task, instead
# of invoking mvn directly.
task_preview() {
task_run
}
# Unit tests plus the Playwright integration tests, against a production build.
#
# The IT profile is what puts the app in production mode; without it the ITs open a
# dev-mode page and find an empty screen. The bundles are cleared first because a
# dev.bundle left behind by `./run.sh run` makes the frontend build decide "a
# production mode bundle build is not needed" and the app then serves a bundle
# that fails to boot — the same trap `clean` exists for, and `mvn clean` alone
# does not clear it since the bundles live under src/.
task_verify() {
setup
clear_bundles
local -a profile=()
if [[ -n "${IT_PROFILE}" ]]; then
profile+=("-P${IT_PROFILE}")
fi
run_mvn clean verify "${profile[@]}" "$@"
}
# Full production build.
task_package() {
setup
run_mvn clean package "$@"
}
task_clean() {
setup
clear_bundles
"${MAVEN}" clean
}
# Describes only what this runner does, never what a project's build is configured to
# do with it: shared text that claims a coverage gate or a browser test is wrong for
# the first project that has neither, and a listed task that then refuses is worse
# than one that was never offered.
usage() {
echo "Usage: ./run.sh <task>"
echo
echo "Tasks:"
if [[ "${CONTAINER_REQUIRED}" == "true" ]]; then
cat <<'EOF'
env [act] The whole development stack — quadlets under podman, compose under
docker: up (default), down, logs, reset (throws the data away)
EOF
fi
cat <<'EOF'
deps Pre-download dependencies and build plugins, so a later task
needs no network
compile Compile sources (triggers a devtools hot-restart of a running app)
bundle Clear cached frontend bundles + touch styles.css + recompile
(use after changing a @CssImport themeFor / @JsModule)
styles Touch styles.css so the browser reloads @imported CSS partials
test Run the unit tests (mvn test)
verify Unit tests + integration tests (mvn clean verify)
run Start the app with spring-boot:run (dev mode)
preview Alias of run, launched by the Claude Code preview pane
package Full production build (mvn clean package)
clean mvn clean + remove cached bundles
help Show this message
EOF
if declare -F project_usage >/dev/null; then
project_usage
fi
}
main() {
local task="${1:-help}"
case "${task}" in
compile) task_compile ;;
bundle) task_bundle ;;
styles) task_styles ;;
env) task_env "${2:-up}" ;;
deps) task_deps ;;
test) task_test "${@:2}" ;;
verify) task_verify "${@:2}" ;;
run) task_run ;;
preview) task_preview ;;
package) task_package "${@:2}" ;;
clean) task_clean ;;
help|-h|--help) usage ;;
*)
# Anything the project's own task file defines, dispatched by name. Only
# a declared function matches, so a mistyped task is still a mistyped
# task rather than a silent no-op.
if declare -F "task_${task}" >/dev/null; then
"task_${task}" "${@:2}"
else
echo "Unknown task: ${task}" >&2
echo >&2
usage >&2
exit 1
fi
;;
esac
}
if [[ -n "${PROJECT_TASKS_FILE}" && -f "${PROJECT_TASKS_FILE}" ]]; then
source "./${PROJECT_TASKS_FILE}"
fi
main "$@"