test(deploy): exercise external module packages #90
@@ -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`, {
|
||||
|
||||
+189
-49
@@ -7,6 +7,8 @@
|
||||
# bash scripts/smoke-deploy.sh builds the image first
|
||||
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
|
||||
# EKKLESIA_SMOKE_TOPOLOGY=split bash scripts/smoke-deploy.sh
|
||||
# EKKLESIA_SMOKE_MODULE_RELEASE_DIR=<dir> EKKLESIA_SMOKE_MODULE_ID=<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 <command...>: 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 <expectation>: "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
|
||||
|
||||
|
||||
@@ -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, " ");
|
||||
|
||||
Reference in New Issue
Block a user