From fa276031336b34270228fa9cb7f0330ade5d96a9 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Fri, 9 Oct 2026 22:18:04 -0500 Subject: [PATCH] test(deploy): exercise external module packages --- scripts/lib/deploy-smoke-client.mjs | 50 ++++-- scripts/smoke-deploy.sh | 238 ++++++++++++++++++++++------ tests/ci-scripts.test.ts | 35 +++- 3 files changed, 252 insertions(+), 71 deletions(-) diff --git a/scripts/lib/deploy-smoke-client.mjs b/scripts/lib/deploy-smoke-client.mjs index db89043..2ff4ddd 100644 --- a/scripts/lib/deploy-smoke-client.mjs +++ b/scripts/lib/deploy-smoke-client.mjs @@ -35,6 +35,10 @@ function required(name) { return value; } +function optional(name) { + return process.env[name] || ""; +} + let failures = 0; function check(condition, description) { @@ -462,36 +466,50 @@ async function ownerCookie() { } /** - * The installed-module checks (ADR 0048): whether `example` is in the module inventory, and what - * its own route returns. Absent, it is in no inventory and its routes are not mounted at all; - * present, its records route returns exactly the expected values, which proves its tables, its - * dependencies, and the platform's database facade all work from module storage. + * The installed-module checks (ADR 0048): whether the selected module is in the UI inventory and, + * when one is supplied, whether its module-owned probe route responds. Core's own `example` fixture + * retains its stronger records assertion, which also proves its database facade. */ async function moduleChecks() { const expectation = required("SMOKE_MODULE_EXPECT"); + const moduleId = optional("SMOKE_MODULE_ID") || "example"; + const probePath = optional("SMOKE_MODULE_PROBE_PATH"); const cookie = await ownerCookie(); const headers = { cookie, origin: dashboardOrigin }; const inventory = await fetch(`${apiOrigin}/api/v1/module-ui`, { headers }); const inventoryBody = await inventory.json(); const installed = - inventory.status === 200 && inventoryBody.modules.some((module) => module.id === "example"); - const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers }); + inventory.status === 200 && inventoryBody.modules.some((module) => module.id === moduleId); if (expectation === "absent") { - check(inventory.status === 200 && !installed, "the module inventory has no `example` module"); check( - records.status === 404 && (await records.text()) === '{"code":"NOT_FOUND"}', - "the absent module's routes are not mounted", + inventory.status === 200 && !installed, + `the module inventory has no \`${moduleId}\` module`, ); } else { - const expected = JSON.parse(expectation); - check(installed, "the module inventory lists the `example` module"); - const values = records.status === 200 ? (await records.json()).map((row) => row.value) : null; - check( - JSON.stringify(values) === JSON.stringify(expected), - `the module's records route returns ${JSON.stringify(expected)}`, - ); + check(installed, `the module inventory lists the \`${moduleId}\` module`); + } + + if (moduleId === "example") { + const records = await fetch(`${apiOrigin}/api/v1/modules/example/records`, { headers }); + if (expectation === "absent") { + check( + records.status === 404 && (await records.text()) === '{"code":"NOT_FOUND"}', + "the absent module's routes are not mounted", + ); + } else { + const expected = JSON.parse(expectation); + const values = records.status === 200 ? (await records.json()).map((row) => row.value) : null; + check( + JSON.stringify(values) === JSON.stringify(expected), + `the module's records route returns ${JSON.stringify(expected)}`, + ); + } + } else if (expectation !== "absent" && probePath) { + const probe = await fetch(`${apiOrigin}${probePath}`, { headers }); + await probe.arrayBuffer(); + check(probe.status === 200, `the module probe ${probePath} responds`); } const signOut = await fetch(`${apiOrigin}/api/auth/sign-out`, { diff --git a/scripts/smoke-deploy.sh b/scripts/smoke-deploy.sh index 5d9a65f..a7bc73d 100755 --- a/scripts/smoke-deploy.sh +++ b/scripts/smoke-deploy.sh @@ -7,6 +7,8 @@ # bash scripts/smoke-deploy.sh builds the image first # EKKLESIA_SMOKE_IMAGE= bash scripts/smoke-deploy.sh # EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh +# EKKLESIA_SMOKE_MODULE_RELEASE_DIR= EKKLESIA_SMOKE_MODULE_ID= \ +# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh # # Either topology (ADR 0045), and in both the same generic image, built once from deploy/Dockerfile. # It contains no module; module storage starts empty, as on a fresh installation. `same-origin` (the @@ -15,10 +17,10 @@ # ADR 0048 end to end with that same image: # # - it boots with no modules; -# - the repository's module template is packaged by the canonical packager as an Ekklesia Module -# Package v1 (ADR 0049; scripts/lib/build-module-package.Dockerfile only provides the build -# environment), verified by the same image's own verifier, extracted to staging, installed into -# module storage, its migrations planned and explicitly applied, and the API restarted; +# - either the repository's module template is packaged by the canonical packager, or an external +# canonical release pair is supplied through EKKLESIA_SMOKE_MODULE_RELEASE_DIR; the package is +# verified by the same image's own verifier, extracted to staging, installed into module storage, +# its migrations planned and explicitly applied, and the API restarted; # - a real headless Chromium drives the Dashboard, the module, its display, and sign-out; # - the module is removed and the API restarted: Core still runs, migration planning reports the # module's retained history, and its data survives; @@ -83,6 +85,28 @@ same-origin | split) ;; ;; esac +module_release_directory="${EKKLESIA_SMOKE_MODULE_RELEASE_DIR:-}" +module_id="${EKKLESIA_SMOKE_MODULE_ID:-example}" +module_probe_path="${EKKLESIA_SMOKE_MODULE_PROBE_PATH:-}" +[[ "$module_id" =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]] || { + echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_ID is not a valid module id." >&2 + exit 1 +} +if [[ -n "$module_release_directory" ]]; then + [[ "$topology" == split ]] || { + echo "smoke-deploy: an external module release requires the split topology." >&2 + exit 1 + } + [[ -d "$module_release_directory" ]] || { + echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_RELEASE_DIR is not a directory." >&2 + exit 1 + } +fi +if [[ -n "$module_probe_path" && "$module_probe_path" != "/api/v1/modules/${module_id}/"* ]]; then + echo "smoke-deploy: EKKLESIA_SMOKE_MODULE_PROBE_PATH must be inside the selected module's API namespace." >&2 + exit 1 +fi + run_id="$(openssl rand -hex 4)" # Unique per invocation, and findable by run and job if this script is killed before its trap runs # (scripts/lib/ci-project.sh, scripts/ci-sweep.sh). Everything started outside Compose below carries @@ -217,22 +241,31 @@ if [[ -z "$image" ]]; then fi if [[ "$topology" == split ]]; then - # The module template as a Module Package v1, from the canonical packager. Never a second platform - # image: the package is installed into module storage below, after the image verifies it. - step "Packaging the module template with the canonical Ekklesia packager" - source_revision="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || true)}" - [[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || fail "no source revision to record in the package." - DOCKER_BUILDKIT=1 docker build --quiet --file scripts/lib/build-module-package.Dockerfile \ - --label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \ - --output "type=local,dest=${work}/module-package" . >/dev/null || - fail "the module package did not build." + # Never a second platform image: the package is installed into module storage below, after the + # generic image verifies it. The default keeps Core's own template smoke self-contained; downstream + # module CI hands this harness the exact release pair it built instead. + if [[ -z "$module_release_directory" ]]; then + step "Packaging the module template with the canonical Ekklesia packager" + source_revision="${GITHUB_SHA:-$(git rev-parse HEAD 2>/dev/null || true)}" + [[ "$source_revision" =~ ^[0-9a-f]{40}$ ]] || fail "no source revision to record in the package." + DOCKER_BUILDKIT=1 docker build --quiet --file scripts/lib/build-module-package.Dockerfile \ + --label "${EKKLESIA_CI_LABEL}=${project}" --build-arg SOURCE_REVISION="$source_revision" \ + --output "type=local,dest=${work}/module-package" . >/dev/null || + fail "the module package did not build." + package_output="$work/module-package" + else + step "Accepting the external module's canonical release pair" + package_output="$module_release_directory" + fi # The archive and its digest are one release: copied together, from this run's own build output, # into a fresh directory the verifier's unprivileged user can read. BuildKit's output directory is # mode 0700 and owned by whoever built it, so it is never handed to the verifier itself. - package_archive="$(module_package_release "$work/module-package" "$work/package")" || + package_archive="$(module_package_release "$package_output" "$work/package")" || fail "the packager did not produce exactly one archive and its digest." - [[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ ]] || - fail "the packager produced an unexpected archive name." + if [[ -z "$module_release_directory" ]]; then + [[ "$package_archive" =~ ^example-[0-9]+\.[0-9]+\.[0-9]+-any\.tar\.gz$ ]] || + fail "the packager produced an unexpected archive name." + fi echo "ok packaged ${package_archive} ($(wc -c <"$work/package/$package_archive") bytes, sha256 $(cut -d' ' -f1 "$work/package/${package_archive}.sha256"))" fi @@ -453,18 +486,41 @@ install_test_module() { -e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \ -v "$daemon_work/package:/package:ro" -v "$daemon_work/staging:/staging" \ --entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \ - --sha256-file "/package/${package_archive}.sha256" --extract-to /staging/example 2>&1 + --sha256-file "/package/${package_archive}.sha256" --extract-to "/staging/${module_id}" 2>&1 )" || fail "the module package did not verify: ${verified}" echo "ok ${verified%%$'\n'*}" docker run --rm --network none --entrypoint sh -v "$daemon_work/modules:/modules" \ - -v "$daemon_work/staging:/staging" "$postgres_image" -ec ' - rm -rf /modules/.incoming-example && cp -R /staging/example /modules/.incoming-example && - rm -rf /staging/example && chown -R 0:0 /modules/.incoming-example && - chmod -R a+rX,go-w /modules/.incoming-example && - mv /modules/.incoming-example /modules/example' || + -v "$daemon_work/staging:/staging" -e MODULE_ID="$module_id" "$postgres_image" -ec ' + incoming="/modules/.incoming-${MODULE_ID}" + installed="/modules/${MODULE_ID}" + staged="/staging/${MODULE_ID}" + rm -rf "$incoming" && cp -R "$staged" "$incoming" && rm -rf "$staged" && + chown -R 0:0 "$incoming" && chmod -R a+rX,go-w "$incoming" && mv "$incoming" "$installed"' || fail "the verified module could not be installed into module storage." } +# The package and its claimed digest are deliberately made to disagree. The verifier must reject it +# before extraction; the valid release pair is left untouched for the installation that follows. +reject_invalid_package() { + local rejected zero_digest + zero_digest="$(printf '0%.0s' {1..64})" + if rejected="$( + docker run --rm --network none --read-only \ + --tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \ + --tmpfs /run/ekklesia-verifier:rw,nosuid,nodev,exec,size=1100m,mode=0700,uid=1000,gid=1000 \ + --cap-drop ALL --security-opt no-new-privileges \ + -e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier \ + -v "$daemon_work/package:/package:ro" \ + --entrypoint node "$image" core/dist/cli/verify-module-package.js "/package/${package_archive}" \ + --sha256 "$zero_digest" 2>&1 + )"; then + fail "the verifier accepted a package whose archive did not match its claimed digest." + fi + grep -q '^\[MODULE_PACKAGE_DIGEST_MISMATCH\]' <<<"$rejected" || + fail "the invalid package was rejected for an unexpected reason." + echo "ok an invalid package is rejected before installation" +} + # restart_api : stops the API, runs the command, and starts the API again. A controlled # restart is the only way a module change is picked up; nothing is ever hot loaded. restart_api() { @@ -476,9 +532,21 @@ restart_api() { fi } +# recreate_api removes the application container, not its installation-owned module bind mount or +# database volumes, then creates a fresh container from the same generic image. +recreate_api() { + "${compose[@]}" stop api >/dev/null 2>&1 || fail "the API did not stop for recreation." + "${compose[@]}" rm --force api >/dev/null 2>&1 || fail "the API container was not removed." + if ! up_output="$("${compose[@]}" up --detach --wait --wait-timeout 180 api 2>&1)"; then + compose_report_not_ready "$up_output" "${compose[@]}" + fail "the recreated API did not become healthy." + fi +} + # module_check : "absent", or the exact JSON array of values `example` must return. module_check() { client module --ip "$module_client_address" -e SMOKE_MODULE_EXPECT="$1" \ + -e SMOKE_MODULE_ID="$module_id" -e SMOKE_MODULE_PROBE_PATH="$module_probe_path" \ -e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" } @@ -491,21 +559,36 @@ if [[ "$topology" == split ]]; then step "Booting the generic image with empty module storage" module_check absent || fail "the image without modules did not run module-free." + step "Rejecting an invalid package before installation" + reject_invalid_package + step "Installing the test module into module storage, migrating explicitly, and restarting the API" install_module_and_migrate() { install_test_module local plan plan="$(plan_output)" - grep -q '^- \[pending\] example:0000_initial ' <<<"$plan" || + grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan" || fail "the plan does not list the installed module's migration as pending." "${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null || fail "applying the installed module's migration failed." + plan="$(plan_output)" + grep -q "^- \\[applied\\] ${module_id}:" <<<"$plan" || + fail "the plan does not list the installed module's migration as applied." + if grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan"; then + fail "the installed module still has a pending migration after apply." + fi + "${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null || + fail "reapplying migrations was not idempotent." } restart_api install_module_and_migrate - module_check '[]' || fail "the installed module did not run from module storage." - psql_as_superuser "INSERT INTO example_records (id, value) VALUES (gen_random_uuid(), 'kept')" \ - >/dev/null || fail "could not write the module's own data." - module_check '["Hello, kept"]' || fail "the installed module did not read its own data." + if [[ "$module_id" == example ]]; then + module_check '[]' || fail "the installed module did not run from module storage." + psql_as_superuser "INSERT INTO example_records (id, value) VALUES (gen_random_uuid(), 'kept')" \ + >/dev/null || fail "could not write the module's own data." + module_check '["Hello, kept"]' || fail "the installed module did not read its own data." + else + module_check present || fail "the installed module did not run from module storage." + fi step "Driving a real browser through the split Dashboard, a module, its display, and sign-out" # Chromium trusts this run's proxy through the public-key hash of Caddy's internal intermediate CA, @@ -516,24 +599,31 @@ if [[ "$topology" == split ]]; then spki="$(openssl x509 -in "$work/caddy-intermediate.crt" -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl base64 -A)" browser_container="${project}-browser" - # The image's entry script passes its arguments on unquoted, so none may contain a space: the - # hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule. - docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \ - --cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \ - --network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \ - "$browser_image" \ - "--ignore-certificate-errors-spki-list=${spki}" \ - --user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start." + start_owner_browser() { + docker rm --force "$browser_container" >/dev/null 2>&1 || true + # The image's entry script passes its arguments on unquoted, so none may contain a space: the + # hostnames reach Chromium through /etc/hosts, which it reads, rather than a resolver rule. + docker run --detach --rm --name "$browser_container" --label "${EKKLESIA_CI_LABEL}=${project}" \ + --cap-drop ALL --security-opt no-new-privileges --tmpfs /tmp \ + --network "$edge_network" --ip "$browser_address" "${host_mappings[@]}" \ + "$browser_image" \ + "--ignore-certificate-errors-spki-list=${spki}" \ + --user-data-dir=/tmp/profile >/dev/null || fail "the headless browser did not start." + } cp scripts/lib/deploy-smoke-browser.mjs "$work/browser.mjs" stage browser.mjs - docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \ - --label "${EKKLESIA_CI_LABEL}=${project}" \ - --network "$edge_network" \ - -e SMOKE_CDP_ADDRESS="${browser_address}:9222" \ - -e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \ - -e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \ - -v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \ - --entrypoint node "$image" /smoke/browser.mjs || fail "browser checks failed." + run_browser_checks() { + start_owner_browser + docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \ + --label "${EKKLESIA_CI_LABEL}=${project}" \ + --network "$edge_network" \ + -e SMOKE_CDP_ADDRESS="${browser_address}:9222" \ + -e SMOKE_HOSTNAME="$hostname" -e SMOKE_DASHBOARD_HOSTNAME="$dashboard_hostname" \ + -e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" \ + -v "$daemon_work/browser.mjs:/smoke/browser.mjs:ro" \ + --entrypoint node "$image" /smoke/browser.mjs + } + run_browser_checks || fail "browser checks failed." if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then # A kiosk: a second browser whose profile directory is a volume, so it outlives the container @@ -610,29 +700,79 @@ if [[ "$topology" == split ]]; then ((display_status == 0)) || fail "display-device checks failed." fi + step "Restarting the API process with the installed module and data intact" + restart_api + if [[ "$module_id" == example ]]; then + module_check '["Hello, kept"]' || fail "the module or its data did not survive an API restart." + else + module_check present || fail "the module did not survive an API restart." + fi + run_browser_checks || fail "the module UI did not survive an API restart." + + step "Recreating the API container with the same module storage and database volumes" + recreate_api + if [[ "$module_id" == example ]]; then + module_check '["Hello, kept"]' || fail "the module or its data did not survive container recreation." + else + module_check present || fail "the module did not survive container recreation." + fi + plan="$(plan_output)" + if grep -q "^- \\[pending\\] ${module_id}:" <<<"$plan"; then + fail "container recreation made an applied module migration pending again." + fi + run_browser_checks || fail "the module UI did not survive container recreation." + if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then + display_driver 120 restart || fail "the display runtime or its data did not survive container recreation." + fi + + step "Disabling and re-enabling the module through the supported lifecycle command" + restart_api "${compose[@]}" run --rm --no-deps migrate node \ + core/dist/cli/set-module-enabled.js --disable --module "$module_id" + module_check absent || fail "the disabled module was still reported as running." + module_storage "test -d /modules/${module_id}" || + fail "disabling the module removed its installed package." + restart_api "${compose[@]}" run --rm --no-deps migrate node \ + core/dist/cli/set-module-enabled.js --enable --module "$module_id" + if [[ "$module_id" == example ]]; then + module_check '["Hello, kept"]' || fail "the re-enabled module did not return with its data." + else + module_check present || fail "the re-enabled module did not return." + fi + run_browser_checks || fail "the re-enabled module UI did not return." + step "Removing the module from module storage and restarting: Core runs, its data stays" - restart_api module_storage 'rm -rf /modules/example' + restart_api module_storage "rm -rf /modules/${module_id}" module_check absent || fail "the removed module is still running." plan="$(plan_output)" - grep -q '^- \[retained\] example: 1 applied migration kept; the module is not installed$' \ + grep -q "^- \\[retained\\] ${module_id}: .*applied migration.*kept; the module is not installed$" \ <<<"$plan" || fail "migration planning did not report the removed module's retained history." grep -q '^Migration plan (.*0 pending, 0 approval-blocked, 1 retained):$' <<<"$plan" || fail "migration planning did not succeed cleanly with the module absent." "${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null || fail "applying migrations failed with the module absent." - [[ "$(psql_as_superuser "SELECT value FROM example_records")" == "kept" ]] || - fail "the removed module's data did not survive." + if [[ "$module_id" == example ]]; then + [[ "$(psql_as_superuser "SELECT value FROM example_records")" == "kept" ]] || + fail "the removed module's data did not survive." + fi echo "ok the module is gone, Core runs, its history is retained, and its data survives" step "Reinstalling the exact same package and restarting: the module returns with its previous data" restart_api install_test_module plan="$(plan_output)" - grep -q '^- \[applied\] example:0000_initial ' <<<"$plan" || + grep -q "^- \\[applied\\] ${module_id}:" <<<"$plan" || fail "the restored module's migration history is not valid." if grep -q '\[retained\]\|\[pending\]' <<<"$plan"; then fail "the restored module left retained or pending migrations." fi - module_check '["Hello, kept"]' || fail "the restored module did not return with its data." + if [[ "$module_id" == example ]]; then + module_check '["Hello, kept"]' || fail "the restored module did not return with its data." + else + module_check present || fail "the restored module did not return." + fi + run_browser_checks || fail "the restored module UI did not return." + if [[ -n "${EKKLESIA_SMOKE_DISPLAY_DEVICE:-}" ]]; then + display_driver 120 restart || fail "the restored display runtime did not return with its data." + fi echo "ok the restored module runs again, with its history strict and its data intact" fi diff --git a/tests/ci-scripts.test.ts b/tests/ci-scripts.test.ts index 4c3b715..4130fd7 100644 --- a/tests/ci-scripts.test.ts +++ b/tests/ci-scripts.test.ts @@ -179,7 +179,7 @@ describe("CI scripts on a shared Docker daemon", () => { expect(packaging).not.toMatch(/pnpm[^\n]*\bdeploy\b/u); expect(deploy).toContain("scripts/lib/build-module-package.Dockerfile"); expect(deploy).toMatch( - /--entrypoint node "\$image" core\/dist\/cli\/verify-module-package\.js[^\n]*--sha256-file[^\n]*--extract-to \/staging\/example/u, + /--entrypoint node "\$image" core\/dist\/cli\/verify-module-package\.js[^\n]*--sha256-file[^\n]*--extract-to "\/staging\/\$\{module_id\}"/u, ); expect(deploy).toContain("--tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m"); expect(deploy).toContain( @@ -187,16 +187,20 @@ describe("CI scripts on a shared Docker daemon", () => { ); expect(deploy).toContain("-e EKKLESIA_MODULE_VERIFIER_SCRATCH_DIR=/run/ekklesia-verifier"); // Module storage only ever receives the verified tree from staging. - expect(deploy).toContain("cp -R /staging/example /modules/.incoming-example"); + expect(deploy).toContain(`staged="/staging/\${MODULE_ID}"`); + expect(deploy).toContain('cp -R "$staged" "$incoming"'); + expect(deploy).toContain('mv "$incoming" "$installed"'); }); it("hand the verifier the package's archive and digest together, from a fresh release pair, never from build output", async () => { const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " "); expect(deploy).toContain('source "$repository_root/scripts/lib/module-package-release.sh"'); // The pair is made from this run's own build output, into a directory that must not exist yet. - expect(deploy).toContain('--output "type=local,dest=${work}/module-package"'); + expect(deploy).toContain(`--output "type=local,dest=\${work}/module-package"`); + expect(deploy).toContain('package_output="$work/module-package"'); + expect(deploy).toContain('package_output="$module_release_directory"'); expect(deploy).toContain( - 'package_archive="$(module_package_release "$work/module-package" "$work/package")"', + 'package_archive="$(module_package_release "$package_output" "$work/package")"', ); expect(deploy).toMatch(/^\s*stage package$/mu); // BuildKit's output directory is 0700 and owned by whoever built it: it is never mounted. @@ -207,11 +211,30 @@ describe("CI scripts on a shared Docker daemon", () => { .split("\n") .find((line) => line.includes("core/dist/cli/verify-module-package.js")); expect(verifier).toContain('-v "$daemon_work/package:/package:ro"'); - expect(verifier).toContain('"/package/${package_archive}"'); - expect(verifier).toContain('--sha256-file "/package/${package_archive}.sha256"'); + expect(verifier).toContain(`"/package/\${package_archive}"`); + expect(verifier).toContain(`--sha256-file "/package/\${package_archive}.sha256"`); expect(verifier).not.toMatch(/--sha256\s/u); }); + it("accepts one exact external module release without putting its source in the platform image", async () => { + const deploy = await source("scripts/smoke-deploy.sh"); + expect(deploy).toContain(`module_release_directory="\${EKKLESIA_SMOKE_MODULE_RELEASE_DIR:-}"`); + expect(deploy).toContain(`module_id="\${EKKLESIA_SMOKE_MODULE_ID:-example}"`); + expect(deploy).toContain('package_output="$module_release_directory"'); + expect(deploy).toContain('-e SMOKE_MODULE_ID="$module_id"'); + expect(deploy).toContain(`--extract-to "/staging/\${module_id}"`); + expect(deploy).toContain(`installed="/modules/\${MODULE_ID}"`); + }); + + it("proves installed module state across restart, container recreation, and lifecycle changes", async () => { + const deploy = await source("scripts/smoke-deploy.sh"); + expect(deploy).toContain(`"\${compose[@]}" rm --force api`); + expect(deploy).toContain("container recreation made an applied module migration pending again"); + expect(deploy).toContain('core/dist/cli/set-module-enabled.js --disable --module "$module_id"'); + expect(deploy).toContain('core/dist/cli/set-module-enabled.js --enable --module "$module_id"'); + expect(deploy).toContain("the restored display runtime did not return with its data"); + }); + it("run every deploy smoke against the one generic image, never a second image with a module in it", async () => { // ADR 0048: the split topology installs its test module into module storage at run time. const deploy = (await source("scripts/smoke-deploy.sh")).replace(/\\\n\s*/gu, " ");