From e30bb60f3abf5d4e0f2244bf00d2571b60aa9959 Mon Sep 17 00:00:00 2001 From: Wynne Pirini Date: Tue, 13 Oct 2026 14:59:55 +1300 Subject: [PATCH] packaging(ns8): fix live-test findings - create-module schema, exec bits, RE2 pattern, CALNODE_IMAGE env Verified against a real single-node NS8 cluster (Rocky 9 VM). Findings: - add-module requires a create-module action to exist and exit 0; the cluster framework already provisions volumes, pre-pulls label images and writes CALNODE_IMAGE, so the module ships only a matching validate-input schema and no scripts. - Action scripts must be committed executable (100755): the agent silently skips non-executable files, which made configure a no-op. CI now asserts indexed modes (this checkout has fileMode=false). - Hostname pattern must be RE2-compatible: the validator rejects the Perl lookahead, so the length guard lives in maxLength instead. - The unit and configure read the app ref from CALNODE_IMAGE in the module environment instead of build-time stamping; the label is the single source of truth. --- .github/workflows/ns8-module.yml | 8 +++++- CLAUDE.md | 5 ++-- packaging/ns8/README.md | 26 ++++++++++++------- packaging/ns8/build-images.sh | 26 ++++++------------- .../actions/configure-module/20configure | 15 +++++++---- .../actions/configure-module/80start_services | 0 .../configure-module/validate-input.json | 2 +- .../actions/create-module/validate-input.json | 21 +++++++++++++++ .../actions/get-configuration/20read | 0 .../imageroot/systemd/user/calnode.service | 6 ++++- 10 files changed, 71 insertions(+), 38 deletions(-) mode change 100644 => 100755 packaging/ns8/imageroot/actions/configure-module/20configure mode change 100644 => 100755 packaging/ns8/imageroot/actions/configure-module/80start_services create mode 100644 packaging/ns8/imageroot/actions/create-module/validate-input.json mode change 100644 => 100755 packaging/ns8/imageroot/actions/get-configuration/20read diff --git a/.github/workflows/ns8-module.yml b/.github/workflows/ns8-module.yml index 56ccfde8..a649e844 100644 --- a/.github/workflows/ns8-module.yml +++ b/.github/workflows/ns8-module.yml @@ -33,7 +33,13 @@ jobs: python3 -c "import json, glob; [json.load(open(f)) for f in glob.glob('imageroot/actions/*/*.json') + glob.glob('ui/public/*.json') + ['ui/public/i18n/en/translation.json']]" bash -n imageroot/actions/configure-module/80start_services bash -n build-images.sh - grep -rn '__CALNODE_APP_IMAGE__' imageroot/systemd/user/calnode.service + # No build-time placeholders may remain: the unit and configure read + # the app ref from ${CALNODE_IMAGE} at install time. + ! grep -rn '__CALNODE_APP_IMAGE__' imageroot/ + # Action scripts must be committed executable: the agent silently + # skips non-executable files, and fileMode=false checkouts do not + # record chmod, so assert the indexed modes, not just the worktree. + git ls-files -s -- imageroot/actions | awk '$1=="100644" && $4 !~ /validate-(input|output)\.json$/ {print "not executable in index:", $4; bad=1} END{exit bad}' build: needs: validate diff --git a/CLAUDE.md b/CLAUDE.md index 1819bfde..cfa2a80b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -246,5 +246,6 @@ at an unconditional `"zoom"`. - The NS8 module is a thin wrapper around the published app image, built by `.github/workflows/ns8-module.yml` with the same tag scheme as `docker-publish.yml`. Release tags pin module and app to the same version. -- The committed systemd unit keeps a `__CALNODE_APP_IMAGE__` placeholder; - `packaging/ns8/build-images.sh` stamps the real app ref at build time. +- The app image ref lives in the `org.nethserver.images` label; at install + the cluster exposes it as `${CALNODE_IMAGE}` and the unit + configure read + it from there. Never hardcode an app tag in `packaging/ns8/`. diff --git a/packaging/ns8/README.md b/packaging/ns8/README.md index 144ecf16..68e1de95 100644 --- a/packaging/ns8/README.md +++ b/packaging/ns8/README.md @@ -81,11 +81,14 @@ into the `calnode-data` volume, then pass its key explicitly, e.g. not expose the key field; cluster backup/restore never needs it (the key travels inside the backed-up module state). -UNTESTED: no live NS8 node was reachable while writing this, so install, -configure, update, backup and restore steps above are documented from the -NS8 module layout but have not been run against a real cluster. The -automated test suite in `tests/` covers install, configure, HTTP check and -removal once a node is available. +Live-tested 2026-10-13 on a single-node Rocky 9 cluster against the +0.10.1-pinned build: install, configure, host-based route (root 302, +`/admin/` 200 through Traefik), SQLite on the `calnode-data` volume, +encryption key byte-identical across reconfigures, and `remove-module +--no-preserve` with automatic route cleanup. Still untested live: cluster +backup/restore (no backup destination was available on the test node) and +the automated `tests/` robot suite, which covers install, configure, HTTP +check and removal once a node is available. ## Uninstall @@ -108,7 +111,7 @@ the same CI as the app. The root workflow `.github/workflows/ns8-module.yml` builds the module image on every PR (no push) and publishes on branch and tag pushes, mirroring the app tags in `docker-publish.yml`: -- push to `main` pushes module `:edge` and `:latest`, running app `:edge` +- push to `main` pushes module `:edge`, running app `:edge` - push to `dev` pushes module `:dev`, running app `:dev` - push a `vX.Y.Z` tag pushes module `:X.Y.Z`, `:X.Y` and `:latest`, running app `:X.Y.Z`, so a module release always runs the matching app release @@ -121,13 +124,16 @@ bash build-images.sh It prints the `buildah push` commands for your registry. Override the app reference per build with e.g. `APP_IMAGE=ghcr.io/calnode/calnode:1.2.3 -IMAGETAGS="1.2.3 latest" PUSH=1 bash build-images.sh`. The systemd unit in -the tree keeps a `__CALNODE_APP_IMAGE__` placeholder that the script stamps -at build time, so the committed files never name an app tag. +IMAGETAGS="1.2.3 latest" PUSH=1 bash build-images.sh`. `APP_IMAGE` only sets +the `org.nethserver.images` label: at install the cluster writes that same +reference into the module environment as `CALNODE_IMAGE`, which is what the +systemd unit and configure-module actually run. The label is the single +source of truth; nothing is stamped at build time. Running the test suite needs an NS8 test cluster; see the [NS8 testing docs](https://github.com/NethServer/ns8-github-actions/blob/v1/README.md#running-tests-locally). -UNTESTED here: no test cluster was available. +From this directory: `run-ns8-tests ghcr.io/calnode/ns8-calnode:latest`. +Not yet run in CI: no test cluster is wired up. ## UI translation diff --git a/packaging/ns8/build-images.sh b/packaging/ns8/build-images.sh index 117aeb52..5abd02dc 100644 --- a/packaging/ns8/build-images.sh +++ b/packaging/ns8/build-images.sh @@ -12,7 +12,11 @@ # APP_IMAGE Calnode app image the module runs, default # ghcr.io/calnode/calnode:latest. Release CI passes the app # tag matching the release, so module 1.2.3 always runs app -# 1.2.3 and the two can never drift apart. +# 1.2.3 and the two can never drift apart. APP_IMAGE is only +# used for the org.nethserver.images label: the systemd unit +# reads the exact reference from ${CALNODE_IMAGE}, which the +# cluster writes into the module environment at install time +# from that same label. One source of truth, no stamping. # IMAGETAGS space-separated module tags to push, default "latest" # PUSH=1 push after building. Default 0 prints the push commands. # @@ -36,22 +40,9 @@ reponame="ns8-calnode" app_image="${APP_IMAGE:-ghcr.io/calnode/calnode:latest}" read -r -a imagetags <<< "${IMAGETAGS:-latest}" -# Stage imageroot with the app image reference stamped in, so the committed -# tree keeps a placeholder and releases pin the exact app tag. Both the -# systemd unit and configure-module's warm-pull default are stamped. -stage=$(mktemp -d) -trap 'rm -rf "${stage}"' EXIT -cp -r imageroot "${stage}/imageroot" -sed -i "s#__CALNODE_APP_IMAGE__#${app_image}#g" \ - "${stage}/imageroot/systemd/user/calnode.service" \ - "${stage}/imageroot/actions/configure-module/20configure" -if grep -q "__CALNODE_APP_IMAGE__" -r "${stage}/imageroot"; then - echo "App image placeholder was not fully substituted." >&2 - exit 1 -fi - -# Create a new empty container image +# Add imageroot directory to the container image container=$(buildah from scratch) +buildah add "${container}" imageroot /imageroot # Reuse existing nodebuilder-ns8-calnode container, to speed up builds if ! buildah containers --format "{{.ContainerName}}" | grep -q nodebuilder-ns8-calnode; then @@ -66,8 +57,7 @@ buildah run \ nodebuilder-ns8-calnode \ sh -c "corepack enable && yarn install && yarn build" -# Add the staged imageroot directory to the container image -buildah add "${container}" "${stage}/imageroot" /imageroot +# Add the UI files to the container image buildah add "${container}" ui/dist /ui # Setup the entrypoint, ask to reserve one TCP port with the label and set a rootless container buildah config --entrypoint=/ \ diff --git a/packaging/ns8/imageroot/actions/configure-module/20configure b/packaging/ns8/imageroot/actions/configure-module/20configure old mode 100644 new mode 100755 index 6748db0b..a66fa729 --- a/packaging/ns8/imageroot/actions/configure-module/20configure +++ b/packaging/ns8/imageroot/actions/configure-module/20configure @@ -25,9 +25,9 @@ import agent.tasks STATE_DIR = os.getcwd() ENV_FILE = os.path.join(STATE_DIR, "calnode.env") CONFIG_FILE = os.path.join(STATE_DIR, "config.json") -# Stamped at build time (build-images.sh) to the exact app ref this module -# release runs, so a pinned install warms the right image instead of :latest. -CALNODE_IMAGE = "__CALNODE_APP_IMAGE__" +# Written by the cluster into the module environment at install time from +# the org.nethserver.images label: the same reference the systemd unit runs. +CALNODE_IMAGE = os.environ.get("CALNODE_IMAGE", "ghcr.io/calnode/calnode:latest") def read_env_file(path): @@ -101,8 +101,13 @@ with open(CONFIG_FILE, "w", encoding="utf-8") as handle: indent=2, ) -# Make sure the app image is present before the service starts. -agent.run_helper("podman", "pull", CALNODE_IMAGE).check_returncode() +# Make sure the app image is present before the service starts. The cluster +# normally pre-pulls it at install; tolerate a registry outage if it is +# already here so a transient network fault cannot block a reconfigure. +pulled = agent.run_helper("podman", "pull", CALNODE_IMAGE) +if pulled.returncode != 0: + present = agent.run_helper("podman", "image", "exists", CALNODE_IMAGE) + agent.assert_exp(present.returncode == 0) # Register a host-based route. Since the "instance" attribute starts with # MODULE_ID, the route is removed automatically with the module. diff --git a/packaging/ns8/imageroot/actions/configure-module/80start_services b/packaging/ns8/imageroot/actions/configure-module/80start_services old mode 100644 new mode 100755 diff --git a/packaging/ns8/imageroot/actions/configure-module/validate-input.json b/packaging/ns8/imageroot/actions/configure-module/validate-input.json index b04a15b9..f58662a2 100644 --- a/packaging/ns8/imageroot/actions/configure-module/validate-input.json +++ b/packaging/ns8/imageroot/actions/configure-module/validate-input.json @@ -10,7 +10,7 @@ "description": "Public fully qualified domain name Calnode is reached at", "minLength": 1, "maxLength": 253, - "pattern": "^(?=.{1,253}$)[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + "pattern": "^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" }, "lets_encrypt": { "type": "boolean", diff --git a/packaging/ns8/imageroot/actions/create-module/validate-input.json b/packaging/ns8/imageroot/actions/create-module/validate-input.json new file mode 100644 index 00000000..9b0e31f4 --- /dev/null +++ b/packaging/ns8/imageroot/actions/create-module/validate-input.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "create-module input", + "$id": "http://schema.nethserver.org/calnode/create-module-input.json", + "description": "Create calnode instance. Volumes and images arrive from the module labels; the cluster framework provisions the volume, pre-pulls the images and writes the *_IMAGE environment entries. No module script needed.", + "type": "object", + "properties": { + "images": { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + }, + "volumes": { + "type": "object", + "description": "Optional mapping of application volume names to filesystem mount points." + } + }, + "additionalProperties": false +} diff --git a/packaging/ns8/imageroot/actions/get-configuration/20read b/packaging/ns8/imageroot/actions/get-configuration/20read old mode 100644 new mode 100755 diff --git a/packaging/ns8/imageroot/systemd/user/calnode.service b/packaging/ns8/imageroot/systemd/user/calnode.service index ca370740..9dbbfc74 100644 --- a/packaging/ns8/imageroot/systemd/user/calnode.service +++ b/packaging/ns8/imageroot/systemd/user/calnode.service @@ -13,6 +13,10 @@ # users and settings are lost. TLS is terminated by the cluster Traefik # instance, so the container only listens on 127.0.0.1. # +# The app image reference comes from ${CALNODE_IMAGE}, which the cluster +# writes into the module environment at install time from the +# org.nethserver.images label. The label is the single source of truth. +# [Unit] Description=calnode server After=network-online.target @@ -33,7 +37,7 @@ ExecStart=/usr/bin/podman run \ --publish=127.0.0.1:${TCP_PORT}:3000 \ --volume=calnode-data:/data \ --env-file=calnode.env \ - __CALNODE_APP_IMAGE__ + ${CALNODE_IMAGE} # 45s grace: the app allows up to 30s HTTP drain plus worker shutdown, so the # default 10s would SIGKILL updates mid-request. Data stays safe either way # (jobs are crash-recoverable), this is about clean connection draining.