Skip to content

feat(ns8): make a cloned instance release its hostname - #157

Merged
shockalotti merged 3 commits into
mainfrom
feat/ns8-clone-release-hostname
Oct 21, 2026
Merged

shockalotti merged 3 commits into
mainfrom
feat/ns8-clone-release-hostname

Conversation

@shockalotti

Copy link
Copy Markdown
Contributor

Follow-up to #156. Adds the clone-module step and documents the behaviour.

First, a correction to what I told you

I said we shipping no clone-module action was a real functional gap. It is not. NethServer/ns8-kickstart, the official module template, declares only configure-module and get-configuration, and the core ships a partial default clone-module implementation that rsyncs the volumes and state/. So cloning already worked. My framing overstated it, and I have fixed the framing rather than quietly doing the work anyway.

What the default does not handle

Two things in state/ belong to one hostname:

  • config.json — the hostname and TLS flags
  • calnode.env — BASE_URL, derived from that hostname

The Traefik route is registered by configure-module and belongs to one instance, so it is not copied; routes are keyed by MODULE_ID. A clone therefore arrives holding the source's hostname with no route of its own, and configuring it with that hostname is then refused, because the source still answers for it and configure-module refuses to steal a hostname another route already serves.

So the clone is not a clean failure. It is a quietly broken instance that looks like a configuration bug. That is worse than not supporting clone at all, which is worth saying plainly.

The step

imageroot/actions/clone-module/20unclaimhostname releases the hostname: config.json is removed and BASE_URL dropped. get-configuration then reports configured:false and the Settings page asks for a hostname, so the clone lands in exactly the state a freshly added instance is in.

It runs at 20, after the core moves data in 10recvstate and before it starts the service in 90finalize.

What it must not release is CALNODE_ENCRYPTION_KEY. It travels with the copied database and is the only thing that can unwrap the database key vault. Dropping it hands back an unreadable copy of every booking, which is strictly worse than the bug being fixed. The rewrite therefore fails loudly rather than writing an env file without the key.

Tests

8 cases in test_clone_module.py, wired into the same validate job as the existing suite, so a regression fails on every push:

  • the key survives while BASE_URL does not
  • config.json is removed
  • a missing config.json or calnode.env is not an error
  • the refusal path when the key would be lost
  • file mode stays 0600
  • comments and blank lines survive the rewrite
  • the step is idempotent, because action steps can be retried

17 tests in the packaging suite in total, all passing.

Honest caveat

This is verified by unit tests and by reading the core implementation. It has not been cloned on a real cluster. That needs a live NS8 node and the robot suite does not cover clone yet.

So I would not tell anyone a clone is supported yet. Worth adding a clone case to the robot suite before this goes out, and that is the one thing here I would want done rather than assumed.

Docs

README gains a Clone and move section, stating that the encryption key is kept and the hostname is not, and why. Better documented than discovered.

@pullfrog

pullfrog Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

The model provider returned an error, so this run stopped.

The error is below. If it names your key, plan or balance, fix that with the provider or pick another model before re-triggering.

Model settings → · Ask in Discord →

provider error: This API key has expired. Create a new API key in OpenCode Console and replace it in your client or secret store. Signing in again will not renew this key.

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using space-bunny-free (free) | 𝕏

@pullfrog

pullfrog Bot commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

The model provider returned an error, so this run stopped.

The error is below. If it names your key, plan or balance, fix that with the provider or pick another model before re-triggering.

Model settings → · Ask in Discord →

provider error: This API key has expired. Create a new API key in OpenCode Console and replace it in your client or secret store. Signing in again will not renew this key.

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using space-bunny-free (free) | 𝕏

@shockalotti

Copy link
Copy Markdown
Contributor Author

Same careful look. This one is good, and the important parts are honest about the risk.

What I verified rather than took on trust:

  • The encryption key really is preserved. Dropping CALNODE_ENCRYPTION_KEY would leave a clone with a database it cannot decrypt, which is worse than the bug being fixed. The script keeps it and drops only BASE_URL.
  • Removing config.json is safe. You claim get-configuration reports configured:false when it is absent. I checked: it catches FileNotFoundError and returns the defaults with configured: False. The claim holds.
  • The tests actually run in CI. The workflow change looks like it only adds a py_compile line, but the existing python3 -m unittest discover -s . -p "test_*.py" step picks up test_clone_module.py. 8 tests, all pass locally.
  • The PR build pushes nothing. The ns8-module publish step is gated on github.event_name != "pull_request", so a fork PR cannot push tags.

One nit, not a blocker. In drop_hostname_from_env, the "refuse to write an env file without the key" check runs after the file has already been rewritten. It cannot actually lose the key, because the function only removes BASE_URL, so it guards against a pre-existing condition rather than a real risk. Moving the check above the write would make it match its own comment.

Sequencing. #156 and this PR both add a section at README line 129, so they conflict. #156 merges first, then rebase this onto main.

The reasoning about routes being keyed by MODULE_ID, and about a clone arriving deliberately unconfigured rather than quietly broken, is correct and worth keeping.

@pullfrog

pullfrog Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

OpenCode Go returned an error, so this run stopped. If it names your key, plan or balance, fix that with the provider or pick another model.

This API key has expired. Create a new API key in OpenCode Console and replace it in your client or secret store. Signing in again will not renew this key.

Pick another model →

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using deepseek-v4.1-flash

Cloning an instance from the cluster admin UI copies the calnode-data volume
and the module state. That is enough to duplicate a working instance, and it was
already working, because the core clone-module action ships a partial default
implementation: ns8-kickstart itself declares only configure-module and
get-configuration, so shipping no clone-module action is normal, not a gap.

What the default does not handle is that two of the things in state/ belong to
ONE hostname:

    config.json  the public hostname and its TLS flags
    calnode.env  BASE_URL, derived from that hostname

The host-based Traefik route is registered by configure-module and belongs to
one instance, so it is not copied: routes are keyed by MODULE_ID. A clone
therefore arrives holding the source's hostname with no route of its own, and
configuring it with that hostname is then REFUSED, because the source still
answers for it and configure-module refuses to steal a hostname that another
route already serves. The clone is not a clean failure, it is a quietly broken
instance that looks like a configuration bug.

The step releases the hostname: config.json is removed and BASE_URL dropped, so
get-configuration reports configured:false and the Settings page asks for a
hostname. The clone is then in exactly the state a freshly added instance is
in, and configure-module registers a route for whatever name it is given.

What must not be released is CALNODE_ENCRYPTION_KEY. It travels with the copied
database and is the only thing that can unwrap the database key vault; dropping
it would hand back an unreadable copy of every booking, which is strictly worse
than the bug being fixed. The rewrite therefore fails loudly rather than writing
an env file without the key, even though that is a state a mis-edited file could
otherwise produce.

Tested offline, 8 cases in test_clone_module.py, wired into the same validate
job as the existing suite so a regression fails on every push: the key survives
while BASE_URL does not, config.json is removed, a missing config.json or
calnode.env is not an error, the refusal path, file mode stays 0600, comments and
blank lines survive the rewrite, and the step is idempotent because action steps
can be retried. 17 tests total in the packaging suite, all passing.

The step runs at 20, after the core moves data in 10recvstate and before it
starts the service in 90finalize.

Honest caveat: this is verified by unit tests and by reading the core
implementation, NOT by cloning on a real cluster. That needs a live NS8 node, and
the robot suite does not cover clone yet. Worth doing before telling anyone a
clone is supported.
core.fileMode is false on this machine, so chmod 755 on the file was invisible
to git and the step was committed as 100644. The validate job's exec-bit guard
caught it: 'not executable in index: imageroot/actions/clone-module/
20unclaimhostname'.

An NS8 action step that is not executable is silently ignored by the agent, so
the step would simply never have run. Fixed with update-index --chmod=+x, which
sets the index mode explicitly rather than trusting the working tree.
Review nit, taken because it is a behaviour difference rather than a style one.
The guard that protects CALNODE_ENCRYPTION_KEY ran after the env file had been
rewritten, so a clone that was going to be refused had already had its env file
mutated by the step that then aborted. Nothing recorded that mutation, so the
clone could end up differing from its source with no trace of why.

The check now runs before the write. The key still cannot be lost either way,
since only BASE_URL is removed and the guard is defending against a pre-existing
condition, but an aborted step now leaves both files exactly as it found them.

New test asserts that: on refusal, calnode.env and config.json are byte-identical
to before. 18 tests, all passing.
@shockalotti
shockalotti force-pushed the feat/ns8-clone-release-hostname branch from cab640d to 9ffc363 Compare October 21, 2026 02:16
@pullfrog

pullfrog Bot commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

OpenCode Go returned an error, so this run stopped. If it names your key, plan or balance, fix that with the provider or pick another model.

This API key has expired. Create a new API key in OpenCode Console and replace it in your client or secret store. Signing in again will not renew this key.

Pick another model →

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using deepseek-v4.1-flash

@shockalotti
shockalotti merged commit 48f634a into main Oct 21, 2026
7 checks passed
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 21, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant