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
81 changes: 81 additions & 0 deletions .github/workflows/ns8-module.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: NS8 module publish

# Builds the ns8-calnode module image from packaging/ns8. The module is a thin
# wrapper: this image carries configuration scripts and the settings UI, while
# the Calnode app itself is pulled from its own registry at install time.
# * PR → validate + build only. Nothing is pushed.
# * push to dev → push `:dev`, running app `:dev`.
# * push to main → push `:edge`, running app `:edge` (`:latest` stays
# pinned to releases, mirroring docker-publish.yml).
# * push a `vX.Y.Z` tag → push `:X.Y.Z`, `:X.Y` and `:latest`, running app
# `:X.Y.Z`. Module and app tags move together, so a
# module release can never point at the wrong app.
on:
push:
branches: [main, dev]
tags: ['v*.*.*']
pull_request:

concurrency:
group: ns8-module-${{ github.ref }}
cancel-in-progress: true

jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Validate action scripts and schemas
working-directory: packaging/ns8
run: |
python3 -m py_compile imageroot/actions/configure-module/20configure imageroot/actions/get-configuration/20read
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

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

# 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
run: |
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
version="${GITHUB_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
echo "module_tags=edge" >> "$GITHUB_OUTPUT"
echo "app_image=ghcr.io/calnode/calnode:edge" >> "$GITHUB_OUTPUT"
elif [[ "${GITHUB_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

# buildah is preinstalled on the ubuntu runners, same as the upstream
# NS8 publish workflow assumes.
- name: Build (push on branch pushes and on tags)
working-directory: packaging/ns8
env:
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
push=1
buildah login -u "${{ github.actor }}" --password-stdin ghcr.io <<<"${{ secrets.GITHUB_TOKEN }}"
fi
PUSH="$push" bash build-images.sh
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,14 @@ at an unconditional `"zoom"`.
- `pnpm` (not npm). Use `pnpm exec <tool>` for local binaries.
- Verify changes against the real app, not just builds — this codebase has been
bitten by CSS that compiles fine but renders wrong.

## Distribution packaging (packaging/)

- One channel per directory: `packaging/ns8/` is the NethServer 8 module
(imageroot actions, systemd unit, Vue settings UI, robot tests). Future
channels (Umbrel, Coolify, TrueNAS, etc.) get siblings, never a mega-PR.
- 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.
139 changes: 139 additions & 0 deletions packaging/ns8/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# ns8-calnode

NethServer 8 community module wrapping [Calnode](https://github.com/calnode/calnode),
the self-hostable scheduling and booking platform. Install it on your cluster
with one command and get Calnode on your own domain. No Calnode docs required.

## How it works

This module is a thin wrapper around the published Calnode container image
(`ghcr.io/calnode/calnode:latest`). Nothing is ported or rebuilt: the module
image only carries configuration scripts and the settings UI. At install time
the cluster pulls the Calnode image and runs it as a rootless container.

Key facts:

- App listens on port 3000 inside the container, published on 127.0.0.1 only.
There is no inbound port beyond the app itself.
- TLS is handled by the cluster Traefik proxy, not the module. Set the
hostname and the module registers a host-based route with Let's Encrypt.
- The whole database is one SQLite file on the `calnode-data` named volume.
The volume survives module updates. Never delete it unless you mean to
wipe all bookings, users and settings.
- First configuration generates a `CALNODE_ENCRYPTION_KEY` that unlocks the
database key vault. It is stored in the module state and covered by cluster
backup. If you lose both the volume and this key, bookings become
unreadable, so keep backups working.

## Install

On the cluster leader, as root:

```
add-module ghcr.io/calnode/ns8-calnode:latest 1
```

Replace `calnode` with the actual registry namespace if you built your own
image (see Development). The command prints the instance name, usually
`ns8-calnode1`.

Then configure it with your public hostname:

```
api-cli run module/ns8-calnode1/configure-module --data '{"host": "book.example.com", "lets_encrypt": true, "http2https": true}'
```

Or open the cluster admin UI, find the instance, and fill in the Settings page:
hostname, Let's Encrypt toggle, HTTP to HTTPS redirect toggle.

Calnode is then live at `https://book.example.com`. Sign up the first admin
account in the web UI. Mail delivery (invites, reminders) is configured
inside Calnode under Settings, using your SMTP smarthost values.

## Update

Update from the Software Center like any other module. The container image is
swapped and the service restarts; the `calnode-data` volume is untouched, so
bookings, users and settings carry over. New module releases track new
Calnode app releases.

To move an existing install to a new hostname, just run `configure-module`
again (or save the Settings page) with the new host. The encryption key and
the database are preserved.

## Backup and restore

Backups run through the standard cluster backup in the cluster admin UI.
Each backup of the instance contains:

- the `calnode-data` volume (the SQLite database file, the whole app state)
- the module state, including `calnode.env` with `CALNODE_ENCRYPTION_KEY`

Restore to the same or a replacement node from the cluster admin backup
section, then re-run `configure-module` with the same hostname if the route
needs recreating. Keep at least one tested backup: the encryption key and
the database are only useful together.

Adopting data from a non-NS8 install is CLI-only: import the SQLite file
into the `calnode-data` volume, then pass its key explicitly, e.g.
`api-cli run module/<id>/configure-module --data '{"host": "...",
"encryption_key": "<key from the old install>"}'`. The Settings page does
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.

## Uninstall

To uninstall the instance, keeping its data for a later reinstall:

```
remove-module --preserve ns8-calnode1
```

To wipe everything including bookings:

```
remove-module --no-preserve ns8-calnode1
```

## Development

This module lives in the Calnode repo under `packaging/ns8/` and ships from
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`

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.

This bullet now contradicts the workflow. ff47709 changed main to publish :edge only, and the workflow's own header comment says :latest stays pinned to releases — but the Development section still advertises a moving :latest on main. Please drop and :latest here so the docs match ns8-module.yml.

- 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

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

```
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.

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.

## UI translation

Translated with [Weblate](https://hosted.weblate.org/projects/ns8/).

To setup the translation process:

- add [GitHub Weblate app](https://docs.weblate.org/en/latest/admin/code-hosting.html#code-hosting-github-notifications) to your repository
- add your repository to [hosted.weblate.org](https://hosted.weblate.org) or ask a NethServer developer to add it to ns8 Weblate project
95 changes: 95 additions & 0 deletions packaging/ns8/build-images.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
#!/bin/bash

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

# Build the ns8-calnode module image and optionally push it.
#
# Run from this directory (packaging/ns8). Environment:
# REPOBASE module image namespace, default ghcr.io/calnode
# 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.
# IMAGETAGS space-separated module tags to push, default "latest"
# PUSH=1 push after building. Default 0 prints the push commands.
#
# The module image only carries configuration scripts and the settings UI.
# The Calnode application itself is pulled from APP_IMAGE at install time.

# Terminate on error
set -e

# Must run from packaging/ns8 (uses relative imageroot and ui paths)
if [ ! -d imageroot ] || [ ! -d ui ]; then
echo "Run build-images.sh from the packaging/ns8 directory." >&2
exit 1
fi

# Prepare variables for later use. Registry namespaces must be lowercase
# (docker:// rejects uppercase); normalize up front so every use below is safe.
repobase="${REPOBASE:-ghcr.io/calnode}"
repobase="${repobase,,}"
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
container=$(buildah from scratch)

# Reuse existing nodebuilder-ns8-calnode container, to speed up builds
if ! buildah containers --format "{{.ContainerName}}" | grep -q nodebuilder-ns8-calnode; then
echo "Pulling NodeJS runtime..."
buildah from --name nodebuilder-ns8-calnode -v "${PWD}:/usr/src:Z" docker.io/library/node:24.21.0-slim
fi

echo "Build static UI files with node..."
buildah run \
--workingdir=/usr/src/ui \
--env="NODE_OPTIONS=--openssl-legacy-provider" \
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
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=/ \
--label="org.nethserver.authorizations=traefik@node:routeadm" \
--label="org.nethserver.tcp-ports-demand=1" \
--label="org.nethserver.rootfull=0" \
--label="org.nethserver.volumes=calnode-data" \
--label="org.nethserver.images=${app_image}" \
"${container}"
# Commit the image (repobase already lowercased at variable prep above).
buildah commit "${container}" "${repobase}/${reponame}:build"

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
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"
fi
Loading
Loading