Skip to content

feat(services): let operators override release service files - #635

Merged
Medformatik merged 8 commits into
feat/release-service-filesfrom
feat/service-file-overrides
Oct 26, 2026
Merged

Medformatik merged 8 commits into
feat/release-service-filesfrom
feat/service-file-overrides

Conversation

@Medformatik

@Medformatik Medformatik commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

What changed and why

This is PR 5b of the plan to stop mounting the whole checkout into the operations agent, stacked on 5a (#634). 5a makes services mount the release's files. This PR makes that safe to ship: operators' deliberate changes become overrides, their checkout edits are carried over once, and a host render can no longer let the checkout decide what runs. Merge it into 5a's branch only once both are approved, so main never gets 5a alone.

Overrides: whole files in infra/docker/overrides/services/<id>/<same relative path>, used in place of the release's file. The directory is git-ignored and Docker-ignored.

  • Rules. An override must be a regular file of the deployment's user, with one link, that neither group nor others may write. Its directories must be the same, and render never reads through a link (O_NOFOLLOW, then fstat). Debian's and Ubuntu's default umask of 002 makes a plain mkdir/cp group-writable, which render refuses. The overrides docs, the basic-auth steps and the release note therefore say to create overrides under umask 022, and how to fix existing ones (chmod -R g-w,o-w). CI's uid 1001, umask 002 job found this.
  • Shipped path, unusable override: the render fails. It names the file, never its contents. Using the release's file instead could silently drop what the override changes, such as a basic-auth gate. A link in place of a directory that holds shipped paths counts as well, and so does a directory at a shipped file's own path (what mkdir -p of the file's path leaves).
  • Unshipped path: reported and ignored. This covers an unknown service, a file the release doesn't have, and generated-routes.yml, which render writes itself. Ignoring them means a release that drops a file never blocks an upgrade.
  • The explicit exception. A YAML or TOML file in Traefik's config/dynamic/ (an operator's auth.yml) is added to Traefik's dynamic directory, even though the release doesn't ship it.
  • Valhalla. An override of valhalla.json is the seed of its writable copy.
  • Base hash. state/ops-agent/overrides.json records each override's bytes and the release file it replaced. Render, the agent's log, check and Run diagnostics flag an override whose release file has changed since. When the override's bytes change, render records the current release file as reviewed: editing the override is how the operator acknowledges the change. The overrides docs say so.

Reporting. Render's output, the agent's log (on each commit and at start), pnpm openmapx check and Run diagnostics report on the service files. Run diagnostics runs the agent's own check. This replaces describeBuiltInServiceDrift (removed). The report covers:

An unusable override reaches the admin panel. A configuration saved there (service config, selection, integration config, enable or disable) is then not applied, and the panel shows the reason instead of "Service administration failed":

  • The agent keeps the reason for that revision. The API asks for it with a new sync operation, configuration.failure.inspect, after a not_committed/failed result.
  • The apply result's strict schema is unchanged, so an API from before this release keeps working against a newer agent. An agent from before this release has no reason to give, so the API falls back to today's generic error.
  • The routes answer 409 { error }. 409 is declared on the five operations; the OpenAPI document and SDK were regenerated. Only file names reach the response.

The one-time import runs inside the commit, before the first generation that uses the release's files. It runs on the agent and in the CLI alike.

  • What it imports. Each checkout service file whose bytes match no version a release ever shipped (5a's history). A panel-updated checkout still holds an earlier release's files, which are not edits. It also imports Traefik's dynamic YAML and TOML files the release doesn't ship (auth.yml), and a differing traefik.yml (the basic-auth@file reference).
  • Only from a checkout at or behind the release. The release's history stops at its build. A checkout ahead of it holds upstream versions that match none of it either, and they would have been imported as overrides. Where the checkout isn't known to be at or behind the release, the import waits: no overrides, no marker, and render, the agent's log, check and Run diagnostics say why, listing the checkout files not used meanwhile. It tries again on every render.
    • Host CLI: git merge-base --is-ancestor HEAD <release revision>. No revision label, a missing revision object, or no git means it waits.
    • Agent (no git): the checkout's own services/service-file-history.json must list no version of a release file that the release's history lacks. CI keeps that file current on every commit (5a), so a commit ahead of the release that changes a service file adds a version the release doesn't know. Files the release no longer names are skipped, except Traefik's dynamic files, which would be imported. A checkout without the file predates 5a, so it is behind. An unreadable or malformed file means it waits. I chose this over comparing service.json hashes because a checkout behind the release, the panel-updated case, usually has older manifests too, so the import would never run there.
    • Residual cases (agent): a commit ahead of the release that changes a service file without regenerating the history (CI rejects that on main, so only local or unchecked commits, which are then treated as the operator's own edits); a history file deleted from a checkout that is ahead; and a fork with its own service-file versions, which waits (safe).
    • While it waits, Traefik fails closed. If the checkout holds a Traefik file the import would carry over (an auth.yml or another dynamic *.yml/*.yaml/*.toml the release doesn't ship, or a differing traefik.yml), the render refuses, on the host and in the agent, so the panel shows the reason. The message names the files and says to put them in infra/docker/overrides/services/traefik/ under umask 022; that ends the wait for those files. check reports it as an error.
    • Every other differing file, Traefik's shipped dynamic files included, keeps the release's bytes and is reported in render's output, check and Run diagnostics.
  • What it never imports. A writableCopy file (valhalla.json), and generated-routes.yml.
  • Nothing in the checkout changes. Imported overrides are 0644, or 0600 under traefik/.
  • A differing file render may not read fails the render, with no marker written, rather than lose the edit.
  • It runs once. state/ops-agent/overrides-import.json records it. Afterwards, checkout edits are reported as unused.
  • No import from the checkout's own files. It doesn't run, and leaves no marker, when the files are the checkout's: no release selected, or OPENMAPX_RELEASE=local. Under local that now holds for the bundled agent as well (5a), and its start-up report reads no bundled files.
  • openmapx.com (read-only check): its checkout is at e5b89eb. Of its 21 service files, only valhalla.json matches no released version, and that file is a writable copy. So the import there imports nothing. The fixture test reproduces this.

Host render rule (decision 6).

  • Run from the checkout with a release selected, render reads that release's service files from its pinned operations-agent image:
    • it runs docker run --rm --network none --read-only <image> node dist/cli.mjs compose release-files, a hidden command that prints the bundle's index;
    • it verifies each file against its hash;
    • it takes the revision from the image's OCI label.
  • Refusal before the import. It refuses while the checkout's service.json files or service files differ from the release's. Overridden files and writable copies are exempt, and so are the edits the import is about to take, which exist only when git says the checkout is at or behind the release. A refused render therefore imports nothing and leaves no marker. Otherwise the commit runs the import with git's answer. When the import waits, the refusal also says why the checkout's edits weren't taken along.
  • The refusal names the differing files and says what to do: wait for the release of this checkout's commit to publish and run compose release again, or git checkout <the release's source revision>. It also points at overrides/.
  • A release that predates this command (an older image) is refused with the same advice. With no release selected, render uses the checkout's files and says so. getting-started now runs compose release before compose render.

Rollback to N−1:

  • The checkout is untouched, so N−1's services mount the operator's original edits again.
  • N−1 accepts this release's generations (5a's test).
  • N−1 ignores overrides/, the store and the Valhalla copy. Edits made only in overrides/ don't apply there; the release note says so.

Panel self-update order:

  • The new agent starts from its predecessor's generation, which records no service files. Its start log says so, and that the next render imports the checkout's edits. An unusable override is reported there, not fatal.
  • The update's own render (services update --no-deps data-manager app-web transitous-runner) runs the import and writes the trees.
  • Those three services mount no service files, so the update recreates nothing that moves. Other containers keep their checkout mounts, which still hold the same bytes, until the operator recreates them. Nothing is recreated automatically.

PR 8 preconditions, added to the plan:

  • no container mounts anything under the checkout's services/;
  • the import has run.

Check's ownership walk. Valhalla runs without a user, so the walk exempts its read-write mount of the copy, data/valhalla/config/valhalla.json, as it does other user-less services' read-write mounts. A root-owned rewrite isn't reported. A test covers it.

Verification

  • Overrides and the import (service-file-overrides.test.ts, against a copy of the repository's real services):

    • overrides of martin.yaml, traefik.yml, auth.yml (in the dynamic directory) and valhalla.json are used, and listed by name with no hash in the output. Removing auth.yml drops it;
    • group-writable, a second link, a symlinked file, a symlinked directory, a directory at martin.yaml's path and a group-writable overrides/services each fail by name;
    • unshipped paths are ignored and reported, security.txt included, and well-known keeps exactly its two files;
    • the stale flag appears when the release changes the file, persists, and clears when the override is edited;
    • the import carries over martin.yaml, auth.yml and a traefik.yml with basic-auth@file. The basic-auth gate reaches the generation and Traefik's directory, and the checkout is untouched;
    • generated-routes.yml and valhalla.json are not imported;
    • the import happens once; a later edit is reported, not imported;
    • the openmapx.com fixture (a checkout one release behind, with no history file, plus Valhalla's rewrite) imports nothing;
    • a checkout one commit ahead (upstream changed martin.yaml and its history lists the new bytes; the operator changed OTP's build-config.json): no overrides and no marker. The report names both files, Martin mounts the release's file, and the import waits again on the next render and for a malformed history. With the release of that commit, only build-config.json is imported;
    • the same checkout with an auth.yml and a gated traefik.yml: the render refuses with the documented message, never with the hash, and commits nothing. With the same two files in overrides/, it renders: the generation's traefik.yml names basic-auth@file, Traefik's directory serves auth.yml, and martin.yaml is still reported as waiting;
    • a local render makes no import, and an unreadable edit fails with no marker.
  • OPENMAPX_RELEASE=local with the bundle's modules swapped in (release-service-files-local.test.ts): the agent's render mounts the checkout's edit and imports nothing. Without local, the same render imports it.

  • Host rule (cli-compose.test.ts, in a real git checkout):

    • a checkout at the release renders the release's file;
    • a checkout behind a later release is refused before the import: no overrides, no marker;
    • an edit in a checkout behind the release is imported and then used;
    • a checkout one commit ahead of the release with a changed upstream file is refused, imports nothing, writes no marker, and says the import waits because HEAD is past the release's revision. With a checkout auth.yml it refuses by that name; once the file is an override, only the martin.yaml difference remains. The same holds when the image names no revision;
    • a changed service.json is refused with the documented message.
  • Admin panel path:

    • the agent doesn't commit with an unusable override, logs the reason and answers configuration.failure.inspect, and commits once the override is fixed;
    • the API turns that into TrustedConfigurationApplyError with status 409 and that message, and falls back with an older agent;
    • POST /admin/services/:id/config answers 409 { error } after restoring the saved values;
    • the web's serviceOrThrow shows the reason.
  • Self-update and check:

    • the agent starts from a predecessor-shaped generation, then the update's render imports and mounts from the store;
    • the agent's start log says the import waits when the checkout's history lists a version the release doesn't;
    • check reports that the import waits and what a host render would refuse when git says the checkout is ahead; otherwise the import to come, the overrides, a container on the checkout and, after the import, the host render's refusal;
    • the ownership walk exempts Valhalla's copy.
  • Release image: I built the ops-agent image locally from this branch. The host's exact docker run … --read-only … compose release-files printed the index: 21 files, 30 manifests, the history, identical to the local bundle's.

  • Checks:

    • the full Vitest suite from the repository root on this branch: 1,924 files passed. One unrelated data-manager timing test (promote.test.ts) failed once and passed on its own rerun;
    • the web project's serviceAdmin test;
    • tsc --noEmit for core, CLI, ops-agent and API;
    • Biome;
    • check-service-files, check-ops-authority, check-dockerfile-sync, check-openapi, check-api-sdk and check-docker-context-secrets.
  • Not performed:

    • a panel self-update;
    • a host update;
    • a rollback on a real stack;
    • screenshots: the only UI change is the text of the existing error message;
    • getting-started's new compose release first on a fresh install. It needs the registry; compose release already writes its own state directory.

    Nothing on the production host was changed; it was only read.

Release and upgrade impact

Product impact: migration, operator-action

release-notes/service-files-from-the-release.md covers 5a and 5b:

  • the import, and that its list should be reviewed;
  • overrides from now on;
  • selecting the release before rendering;
  • that the first stack-wide compose up recreates every service whose mounts moved, PostGIS and Traefik included, while the documented host update (services update with a list) recreates only the listed ones;
  • the PR 8 wait;
  • rollback.

Docs:

  • Managing services: a new "Changing a service's files" section, including how editing an override acknowledges a release change;
  • Upgrading;
  • getting-started;
  • supporting-infrastructure: basic auth through overrides;
  • routing, transit and map-tiles guides;
  • the service manifest contract (writableCopy, generating the file list).

No package release: I compared tsup builds of @openmapx/extension-cli at 5a and at this branch, with esbuild's identifier renumbering normalized away. chunk-*.js and server-*.js are identical. cli.js gains only:

  • the ops contract's new configuration.failure.inspect kind, policy and schemas;
  • reordered import blocks;
  • the module-level schemas of release-service-files.ts, moved.

None of the functions it calls change. 5a's changeset covers writableCopy. After the review fixes, I rebuilt @openmapx/extension-cli at this branch: it bundles none of the changed modules (import, overrides, writable copies, release service files).

@Medformatik Medformatik added documentation Documentation content, tooling or workspace dependencies docker Container images and Docker build or deployment tooling labels Oct 26, 2026
@Medformatik Medformatik self-assigned this Oct 26, 2026
@Medformatik
Medformatik force-pushed the feat/service-file-overrides branch from 01f4504 to bf2cbea Compare October 26, 2026 00:14
Comment thread packages/core/src/services/__tests__/service-file-overrides.test.ts Fixed
Whole files in infra/docker/overrides/services/<id>/<path> replace the
release's, owned by the deployment user, writable by no one else and never
through a link; Traefik's dynamic directory also takes files the release
doesn't ship (auth.yml). An unusable override of a shipped file fails the
render, and the admin panel shows why through configuration.failure.inspect;
one of an unshipped path is reported and ignored. Render, the agent's log,
check and Run diagnostics list the overrides and flag those whose release
file changed since they were made, which replaces the drift warning.

The first render with the release's files imports, once, checkout edits
that match no version a release shipped, never files a container writes. A
host render reads the selected release's files from its agent image and
refuses while the checkout's manifests or files differ from them.
An imported override keeps its checkout file's mode, never writable by
others, and owner-only under traefik/. The authority inventory lists the
new configuration.failure.inspect operation, and the fixed CLI's test checks
the release's service file report instead of the replaced drift warning.
Under umask 002 a plain mkdir or write leaves an override group-writable,
which render rightly refuses before the case the test means. The tests set
the mode they mean, and the docs and release note tell operators on Debian
and Ubuntu to create overrides under umask 022.
@Medformatik
Medformatik force-pushed the feat/service-file-overrides branch from b8017c9 to e67f152 Compare October 26, 2026 00:27
…rides

# Conflicts:
#	packages/core/src/services/writable-copies.ts
…d the release

A checkout ahead of the selected release holds upstream versions its
history doesn't know, which the import took for operator edits. It now
waits there, importing and recording nothing, and render, check and the
agent's log say why and which checkout files go unused meanwhile.

- The host CLI asks git whether HEAD is the release's source revision or
  one before it, and runs the refusal check before the import: only the
  edits the import will take are exempt from it.
- The agent, without git, compares the checkout's own
  services/service-file-history.json with the release's: a version the
  release doesn't list means the checkout may be ahead.
- Under OPENMAPX_RELEASE=local the agent's start-up report reads no
  bundled files, as its renders already don't.
- A directory at a shipped file's path in overrides fails the render
  instead of leaving the release's file in use.
… carry over

A checkout auth.yml (or another dynamic file the release doesn't ship) or a changed traefik.yml would otherwise give way to the release's files and drop a basic-auth gate. The render, host or agent, names them and says to put them in overrides, which ends the wait for those files; check reports it as an error. Other differing files keep the release's bytes and are reported.
@Medformatik
Medformatik merged commit 88f854e into feat/release-service-files Oct 26, 2026
28 checks passed
@Medformatik
Medformatik deleted the feat/service-file-overrides branch October 26, 2026 16:31
@github-actions github-actions Bot locked and limited conversation to collaborators Oct 26, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

docker Container images and Docker build or deployment tooling documentation Documentation content, tooling or workspace dependencies

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants