Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 63 additions & 9 deletions .github/workflows/ns8-module.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ concurrency:
jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@v7

Expand All @@ -33,55 +36,106 @@ 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
bash -n push-manifest.sh
# 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}'
# The non-empty assertion matters: the awk below exits 0 on empty
# input, so a pathspec that stops matching would pass vacuously.
scripts=$(git ls-files -s -- imageroot/actions | grep -v 'validate-.*\.json$')
if [ -z "$scripts" ]; then
echo "::error::no action scripts matched, the exec-bit check below would pass vacuously"
exit 1
fi
echo "$scripts" | awk '$1!="100755" {print "not executable in index:", $4; bad=1} END{exit bad}'

build:
needs: validate
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v7

# Required to build the arm64 variant of the module image on an amd64
# runner. Without it the module image would be amd64-only and podman
# could not run it on an arm64 NS8 node.
- uses: docker/setup-qemu-action@v4
with:
platforms: all

# The UI build is the slow part; cache the dependency tree the way the
# upstream NS8 publish workflow does, so branch and PR runs do not
# re-download the whole Vue/Carbon tree every time.
- uses: actions/cache@v4
with:
path: packaging/ns8/ui/node_modules
key: "yarn-${{ hashFiles('packaging/ns8/ui/yarn.lock') }}"

# Mirror the app tagging in docker-publish.yml: release tags pin both
# images to the same version, branches track the moving app tags.
- name: Resolve tags
id: tags
env:
REF: ${{ github.ref }}
REF_NAME: ${{ github.ref_name }}
run: |
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
version="${GITHUB_REF_NAME#v}"
if [[ "${REF}" == refs/tags/v* ]]; then
version="${REF_NAME#v}"
echo "module_tags=${version} ${version%.*} latest" >> "$GITHUB_OUTPUT"
echo "app_image=ghcr.io/calnode/calnode:${version}" >> "$GITHUB_OUTPUT"
elif [[ "${GITHUB_REF}" == refs/heads/main ]]; then
elif [[ "${REF}" == refs/heads/main ]]; then
echo "module_tags=edge" >> "$GITHUB_OUTPUT"
echo "app_image=ghcr.io/calnode/calnode:edge" >> "$GITHUB_OUTPUT"
elif [[ "${GITHUB_REF}" == refs/heads/dev ]]; then
elif [[ "${REF}" == refs/heads/dev ]]; then
echo "module_tags=dev" >> "$GITHUB_OUTPUT"
echo "app_image=ghcr.io/calnode/calnode:dev" >> "$GITHUB_OUTPUT"
else
echo "module_tags=build" >> "$GITHUB_OUTPUT"
echo "app_image=ghcr.io/calnode/calnode:edge" >> "$GITHUB_OUTPUT"
fi

- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# buildah is preinstalled on the ubuntu runners, same as the upstream
# NS8 publish workflow assumes.
- name: Build (push on branch pushes and on tags)
- name: Build each architecture
working-directory: packaging/ns8
env:
EVENT_NAME: ${{ github.event_name }}
ACTOR: ${{ github.actor }}
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPOBASE: ghcr.io/${{ github.repository_owner }}
APP_IMAGE: ${{ steps.tags.outputs.app_image }}
IMAGETAGS: ${{ steps.tags.outputs.module_tags }}
run: |
push=0
if [ "${{ github.event_name }}" != "pull_request" ]; then
if [ "$EVENT_NAME" != "pull_request" ]; then
push=1
buildah login -u "${{ github.actor }}" --password-stdin ghcr.io <<<"${{ secrets.GITHUB_TOKEN }}"
buildah login -u "$ACTOR" --password-stdin ghcr.io <<<"$GHCR_TOKEN"
fi
PUSH="$push" bash build-images.sh
# PUSH=1 publishes only the per-arch :build-<arch> tags. The public
# tags are assembled from them by the manifest step below.
for arch in amd64 arm64; do
PUSH="$push" TARGETARCH="$arch" bash build-images.sh
done

- name: Assemble and push the multi-arch manifest
if: github.event_name != 'pull_request'
working-directory: packaging/ns8
env:
REPOBASE: ghcr.io/${{ github.repository_owner }}
TARGETARCHES: "amd64 arm64"
IMAGETAGS: ${{ steps.tags.outputs.module_tags }}
run: bash push-manifest.sh
20 changes: 15 additions & 5 deletions packaging/ns8/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,16 +116,26 @@ tag pushes, mirroring the app tags in `docker-publish.yml`:
- 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

Because `:latest` only moves on a release tag, use `:edge` to try the newest
module on a test cluster.

Every published module tag is a **multi-arch** manifest (amd64 + arm64). NS8
runs modules as rootless containers on the node, so a single-arch image would
install on amd64 and fail on arm64. CI builds each arch to a private
`:build-<arch>` tag and only then assembles the public tags, so an interrupted
release cannot publish a single-arch image under a version tag.

To build locally on a machine with podman and buildah, from this directory:

```
bash build-images.sh
PUSH=1 TARGETARCH=amd64 bash build-images.sh
PUSH=1 TARGETARCH=arm64 bash build-images.sh
TARGETARCHES="amd64 arm64" IMAGETAGS="latest" bash push-manifest.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`. `APP_IMAGE` only sets
the `org.nethserver.images` label: at install the cluster writes that same
Override the app reference per build with e.g. `APP_IMAGE=ghcr.io/calnode/calnode:1.2.3
IMAGETAGS="1.2.3 1.2 latest"`. `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.
Expand Down
35 changes: 22 additions & 13 deletions packaging/ns8/build-images.sh
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
# 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.
# TARGETARCH architecture to build for, default amd64. NS8 runs modules
# as rootless containers on the node, so an amd64-only module
# image cannot be installed on an arm64 node: build each arch
# and assemble them into one manifest tag (see push-manifest.sh).
# IMAGETAGS space-separated module tags to push, default "latest"
# PUSH=1 push after building. Default 0 prints the push commands.
#
Expand All @@ -39,9 +43,13 @@ repobase="${repobase,,}"
reponame="ns8-calnode"
app_image="${APP_IMAGE:-ghcr.io/calnode/calnode:latest}"
read -r -a imagetags <<< "${IMAGETAGS:-latest}"
targetarch="${TARGETARCH:-amd64}"

# Add imageroot directory to the container image
container=$(buildah from scratch)
# Per-arch build tag. push-manifest.sh stitches these into the public tags.
buildtag="build-${targetarch}"

# Add imageroot directory to the container image, pinned to this arch.
container=$(buildah from --arch "${targetarch}" scratch)
buildah add "${container}" imageroot /imageroot

# Reuse existing nodebuilder-ns8-calnode container, to speed up builds
Expand All @@ -68,18 +76,19 @@ buildah config --entrypoint=/ \
--label="org.nethserver.images=${app_image}" \
"${container}"
# Commit the image (repobase already lowercased at variable prep above).
buildah commit "${container}" "${repobase}/${reponame}:build"
buildah commit "${container}" "${repobase}/${reponame}:${buildtag}"

if [ "${PUSH:-0}" = "1" ]; then
for imagetag in "${imagetags[@]}"; do
buildah tag "${repobase}/${reponame}:build" "${repobase}/${reponame}:${imagetag}"
buildah push "${repobase}/${reponame}:${imagetag}" "docker://${repobase}/${reponame}:${imagetag}"
done
# Push only the per-arch tag. The public tags are assembled from these by
# push-manifest.sh, so a half-finished release can never publish a
# single-arch image under a version tag.
buildah push "${repobase}/${reponame}:${buildtag}" \
"docker://${repobase}/${reponame}:${buildtag}"
else
printf "Built %s:%s (app %s).\nPublish with:\n\n" "${repobase}/${reponame}" "build" "${app_image}"
for imagetag in "${imagetags[@]}"; do
printf " buildah tag %s:build %s:%s\n" "${repobase}/${reponame}" "${repobase}/${reponame}" "${imagetag}"
printf " buildah push %s:%s docker://%s:%s\n" "${repobase}/${reponame}" "${imagetag}" "${repobase}/${reponame}" "${imagetag}"
done
printf "\nOr rerun with PUSH=1 IMAGETAGS=\"...\" to push directly.\n"
printf "Built %s:%s (arch %s, app %s).\n\n" "${repobase}/${reponame}" "${buildtag}" "${targetarch}" "${app_image}"
printf "To publish this arch and assemble the multi-arch tags:\n\n"
printf " PUSH=1 TARGETARCH=%s IMAGETAGS=\"%s\" bash build-images.sh\n" \
"${targetarch}" "${imagetags[*]}"
printf " TARGETARCHES=\"%s\" IMAGETAGS=\"%s\" bash push-manifest.sh\n" \
"${targetarch}" "${imagetags[*]}"
fi
5 changes: 3 additions & 2 deletions packaging/ns8/imageroot/actions/configure-module/20configure
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
# 2. write the container env file state/calnode.env (BASE_URL follows
# the hostname; the encryption key is generated once and preserved
# across reconfigures, since losing it locks the SQLite data key)
# 3. register a host-based Traefik route (TLS is handled by the cluster)
# 4. pull the Calnode image so a first install works offline of updates
# 3. ensure the app image is present (tolerating a registry outage when
# the image is already local)
# 4. register a host-based Traefik route (TLS is handled by the cluster)
#
import json
import os
Expand Down
60 changes: 60 additions & 0 deletions packaging/ns8/push-manifest.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/bin/bash

#
# Copyright (C) 2023 Nethesis S.r.l.
# SPDX-License-Identifier: GPL-3.0-or-later
#

# Assemble the per-arch module images built by build-images.sh into one
# multi-arch tag.
#
# NS8 runs each module as a rootless container on the node, so the module
# image must exist for every node architecture. A single-arch image would
# install fine on amd64 and fail on arm64, so the per-arch tags are never
# published as-is: only the assembled tags are public.
#
# Run from this directory (packaging/ns8), after pushing each arch with
# build-images.sh. Environment:
# REPOBASE module image namespace, default ghcr.io/calnode
# TARGETARCHES space-separated arches to assemble, required
# IMAGETAGS space-separated tags to publish, default "latest"
#
# Example:
# TARGETARCHES="amd64 arm64" IMAGETAGS="1.2.3 1.2 latest" bash push-manifest.sh

set -e

repobase="${REPOBASE:-ghcr.io/calnode}"
repobase="${repobase,,}"
reponame="ns8-calnode"
read -r -a arches <<< "${TARGETARCHES:-}"
read -r -a imagetags <<< "${IMAGETAGS:-latest}"

if [ "${#arches[@]}" -eq 0 ]; then
echo "TARGETARCHES is required, e.g. TARGETARCHES=\"amd64 arm64\"" >&2
exit 1
fi

for arch in "${arches[@]}"; do
if ! buildah image exists "${repobase}/${reponame}:build-${arch}"; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildah has no image subcommand — it is buildah images (list) and buildah manifest exists (checks a manifest list). buildah image exists … fails with an unknown-command error, and because the test is if ! buildah image exists …, that failure is read as "image missing", so the loop always prints Missing local image … and the script exits 1 before assembling anything.

Technical details
# Wrong existence check in push-manifest.sh

## Affected sites
- `packaging/ns8/push-manifest.sh:39` — `buildah image exists "${repobase}/${reponame}:build-${arch}"`

## Required outcome
- The check must exit 0 when the per-arch image is present locally and non-zero when it is not.

## Suggested approach (optional)
- `buildah inspect --type image "${repobase}/${reponame}:build-${arch}" >/dev/null 2>&1`, or
- `buildah images --format '{{.Name}}:{{.Tag}}' | grep -qxF "${repobase}/${reponame}:build-${arch}"`.
- Verified against buildah source: `manifest init()` registers no `image` command (only `images`), and the `buildah(1)` command table lists no `image` entry.

echo "Missing local image for ${arch}. Run build-images.sh with" \
"TARGETARCH=${arch} PUSH=1 first." >&2
exit 1
fi
done

for imagetag in "${imagetags[@]}"; do
manifest="${repobase}/${reponame}:${imagetag}"
# Drop any stale manifest so a retry cannot inherit old entries.
buildah manifest rm --all "${manifest}" 2>/dev/null || true

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildah manifest rm takes no --all flag — its only arguments are the manifest-list names. The unknown-flag error is swallowed by 2>/dev/null || true, so this line never removes anything and the "drop any stale manifest" the comment promises does not happen. On a re-run with the same tag, manifest create would then fail with already exists.

Technical details
# `buildah manifest rm --all` is not a valid invocation

## Affected sites
- `packaging/ns8/push-manifest.sh:49` — `buildah manifest rm --all "${manifest}" 2>/dev/null || true`

## Required outcome
- Remove any existing local manifest list named `${manifest}` before recreating it, without erroring when none exists.

## Suggested approach (optional)
- `buildah manifest rm "${manifest}" 2>/dev/null || true`.
- `manifest rm` has no flags registered; only the `rm`/`remove` positional list names are accepted.

buildah manifest create "${manifest}"
for arch in "${arches[@]}"; do
buildah manifest add \
"${manifest}" \
"${repobase}/${reponame}:build-${arch}" \
"docker.io/calnode/ns8-calnode:${imagetag}-${arch}"
Comment on lines +52 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

buildah manifest add accepts exactly two positional arguments, <list> <image>; a third argument is rejected (At least two arguments are necessary: list and image to add to list on the runner's buildah, too many arguments… on current main). It is only treated as extra input when --artifact is set. With set -e this aborts the script, so no public tag is ever pushed.

The image's platform is already taken from its config, and buildah from --arch stamps that correctly, so the third argument is not needed.

Technical details
# Too many arguments to `buildah manifest add`

## Affected sites
- `packaging/ns8/push-manifest.sh:52-55` — `buildah manifest add "${manifest}" "${repobase}/${reponame}:build-${arch}" "docker.io/calnode/ns8-calnode:${imagetag}-${arch}"`

## Required outcome
- Add exactly the local per-arch image to the manifest list and let its recorded platform come from the image config.

## Suggested approach (optional)
- Drop the third argument entirely, i.e. `buildah manifest add "${manifest}" "${repobase}/${reponame}:build-${arch}"`.
- If you want the platform to be explicit rather than inferred, add `--arch "${arch}"`.
- Note the dropped value also hardcodes `docker.io/calnode/ns8-calnode`, which diverges from `${repobase}/${reponame}` (e.g. a fork's owner) — another reason to remove it rather than repoint it.

## Verified
- `buildah manifest add` non-artifact path rejects `len(args) > 2`; checked in both buildah `main` and `v1.33.7` (Ubuntu 24.04 runner).
- `buildah from --arch arm64 scratch` + `buildah commit` records `Architecture=arm64` in the config (`fixupConfig` uses `systemContext.ArchitectureChoice`), so `manifest add` reads the right platform without `--arch`.

done
buildah manifest push --all "${manifest}" "docker://${manifest}"
buildah manifest rm "${manifest}"
echo "Published ${manifest} (${arches[*]})"
done
Loading