Status: active
Quick-start for authoring, signing, publishing, and assigning a new NodeModule. Covers manifest.yaml schema, package_spec / file_spec / protected_spec semantics, Containerfile patterns, two-stage CI pipeline, and Cosign static-key signing (Gitea Actions isn't on Sigstore Fulcio's trusted-issuer list, so keyless signing never verifies server-side — see Phase 5).
Audience: module authors (internal + external open-source contributors), template designers composing fleet-wide assignments.
Added 2026-07-28 after a proposal to split five new modules out of dev-cell
was cut to one. Module sprawl is a real cost: every module is a manifest, a
build arm, a publish, a template assignment, a version to bump, and another
entry in every composing node's LKG manifest. Answer this BEFORE Phase 1.
A new module must satisfy at least one of:
- R1 — Two or more real consumers today, or a hard
requires: capability:<name>edge from another module's manifest. "Assumed co-assigned on the same template" prose does NOT count: the resolver cannot enforce it, so it protects nothing. - R2 — An independent third-party payload with its own version/CVE cadence.
The per-module CVE remediation pipeline (
package_module_refresh,rolling_module_upgrade) operates per module, so a vendored upstream binary needs its own boundary to be CVE-bumped without dragging an unrelated module's churn. This is why traefik, gitleaks, act_runner and Chrome are separate. - R3 — An opt-in heavy payload a node type must be able to EXCLUDE
(
dev-cell-browser,dev-cell-docker).
Otherwise, bake it into the owning node-type module's own build arm and
file_spec. Four of the five candidates in that review failed all three prongs
and stayed in dev-cell; runtime-go passed on R2 alone (Go ships security
releases roughly monthly, while dev-cell's scripts churn constantly — baked in,
every script tweak would re-ship a ~150MB-heavier blob and every Go CVE would
ride dev-cell's release treadmill).
Note that "it completes a family" is the WEAKEST justification — a family label
can rationalise any new member. runtime-* is demand-driven and should stay
that way: a language runtime earns a module only when the distro package cannot
satisfy the floor (ruby 3.2.8, node 24, go 1.25 all can't), never by analogy.
Where functionality belongs, once you have decided it is not its own module:
| Kind | Home |
|---|---|
| Fleet-wide generic mechanism | base-os-* (model: the ssh-hostkeys generalisation) |
| Per-node-type policy | the node-type module — never base-os |
| Per-instance data | never in any blob: identity envelope / fw_cfg / /persist |
That last row is an invariant, not a preference: a non-instance/node-specific module must not contain instance/node-specific files, and no instance-specific file belongs in a base-os build.
| Concept | What it is | Backing model |
|---|---|---|
| NodeModule | A reusable userspace component (e.g., nginx, k3s-server). Has a category + variety. | NodeModule |
| NodeModuleCategory | Ordered grouping (network=60, container runtimes=70, userland=90+). A platform-side DB row, not a manifest key — see note below. | NodeModuleCategory |
| Module variety | subscription (always-on) / config (overrides another module's config) / instance (per-instance customization) |
enum on NodeModule |
| NodeModuleVersion | A specific build of a module. State column is promotion_state: built → staging → blessed → live, with retired as the terminal/rollback state |
NodeModuleVersion |
| manifest.yaml | Authoring-time spec describing module identity + composition rules | YAML at the root of module-repo |
| package_spec | Debian packages installed via mmdebstrap in the Containerfile builder stage |
YAML field |
| file_spec | rsync-glob patterns determining which files from the rootfs/ tree end up in the module artifact | YAML field |
| protected_spec | Files this module owns — overrides from higher-priority modules are forbidden | YAML field |
| dependency_spec | Other modules this one requires (resolved by DependencyResolutionService) |
YAML field |
| Containerfile | Dockerfile-style recipe for the module's builder image (used by Gitea Actions to produce the rootfs) | Dockerfile syntax |
| erofs digest | fs-verity hash committed to the OCI artifact; agent verifies before mounting | sha256 |
The canonical layout lives at templates/module-repo/ — copy it as a starting point:
my-module/
├── manifest.yaml # the spec (this runbook focuses on it)
├── Containerfile # builder image (mmdebstrap → rootfs)
├── rootfs/ # files copied into the module artifact
│ └── etc/
│ └── nginx/
│ └── nginx.conf
└── .gitea/
└── workflows/
└── build.yaml # two-stage CI: builder → composer
Create a Gitea repository under registry.example.com/<account>/modules/my-module (private by default; public is allowed for community modules). Push the skeleton.
Minimum viable manifest:
schema_version: 1
# Identity — flat top-level keys (no `identity:` wrapper).
name: my-nginx
display_name: "nginx 1.26 with TLS hardening"
description: "nginx 1.26 with TLS hardening + /healthz endpoint"
license: "BSD-2-Clause"
# Packages installed in the Containerfile builder stage via mmdebstrap.
# These end up in /var/lib/dpkg/status of the resulting rootfs.
package_spec:
- nginx
- nginx-extras
# Paths this module OWNS in the artifact (rsync-include, flat glob list).
file_spec:
- "/etc/nginx/**"
- "/var/www/healthz/**"
# Paths to EXCLUDE from this module's blob (rsync-style mask, local-only).
mask:
- "/etc/nginx/sites-enabled/default" # don't ship the default vhost
# Files this module owns — no neighbor may ship these. Folded into every
# neighbor's effective_mask in both priority directions.
protected_spec:
- "/etc/nginx/conf.d/00-security.conf"
# Other modules this one requires/provides. Resolved transitively by
# DependencyResolutionService. `requires` form: <owner>/<module>@<constraint>.
dependencies:
requires:
- "powernode/system-base@^1.0"
- "powernode/security-hardening@^1.0"
- "powernode/chrony@^1.0" # NTP for cert validation
provides:
- "http-server"Field semantics:
name— globally unique within the account (platform appends a hash to disambiguate across accounts). The manifest'snameis the stable identifier; downstream tooling looks up theNodeModulerow by it.package_spec— apt packages installed in the Containerfile builder. Applied via mmdebstrap to the rootfs.file_spec— flat array of rsync-style glob strings identifying paths this module owns. The artifact ships these.mask— paths to EXCLUDE from this module's blob at build time. Local-only — does NOT affect neighbor modules' blobs.protected_spec— files this module owns that NO neighbor module may ship. The build pipeline folds these into every neighbor's effective_mask in both priority directions, so a sensitive lower-module file (e.g./etc/shadowfrom system-base) cannot be overridden by a service module's overlay layer.dependencies.requires— modules pulled in transitively. Form is<owner>/<module>@<version-constraint>.
Important — these are NOT in the manifest: category, variety, cosign_identity_regexp, cosign_issuer_regexp live on the platform-side NodeModule DB row (set at registration time via the operator UI at /app/system/modules/new, or as the category_id: argument to system_create_module_from_package). They are not validated by the manifest schema. The NodeModuleCategory a module belongs to is a platform-side DB row selected at registration — the seeded slugs (system-base, network-overlay, container-runtimes, security-hardening, userland) are operator-facing taxonomy on that row, never a manifest field. variety accepts subscription (turn it on; always present once assigned — e.g. nginx, k3s-server), config (modifies another module's config without rebuilding it — e.g. daemon-json-override for slice 10), or instance (per-NodeInstance customisation — higher effective_priority than subscription).
For the authoritative shape see extensions/system/templates/module-repo/manifest.yaml and extensions/system/modules/.schema/module-manifest.schema.json. The MODULE_MANIFEST_COMPLETE_SCHEMA.md doc (in the parent docs/ directory) is the operator-facing prose reference.
The Containerfile produces the builder image — the stage that runs mmdebstrap, drops files into a clean rootfs, and emits the module artifact.
# templates/module-repo/Containerfile
FROM ghcr.io/powernode/module-builder:latest AS builder
WORKDIR /work
# Copy your manifest and rootfs tree
COPY manifest.yaml ./
COPY rootfs/ ./rootfs/
# The base image's entrypoint reads manifest.yaml and:
# 1. Runs mmdebstrap with package_spec → /work/build/rootfs/
# 2. rsync-copies your rootfs/ tree on top per file_spec rules
# 3. mkfs.erofs → erofs digest
# 4. Emits the artifact at /work/dist/module.tar
ENTRYPOINT ["/usr/local/bin/build-module"]The base image ghcr.io/powernode/module-builder provides a hermetic build environment with mmdebstrap, mkfs.erofs (erofs-utils), and cosign. Don't deviate from it unless you need a custom debian release.
rootfs/ tree:
rootfs/
└── etc/
└── nginx/
├── conf.d/
│ ├── 00-security.conf # in protected_spec — owned by this module
│ └── 10-app.conf # composable; lower-priority modules can override
└── nginx.conf
The platform's authority on file paths trumps your repo: if a higher-priority module owns /etc/nginx/nginx.conf via its protected_spec, your file_spec for it is silently dropped during composition.
Test the manifest locally before pushing:
# From your module-repo working tree
docker run --rm \
-v $PWD:/work:ro \
-v $PWD/dist:/work/dist \
ghcr.io/powernode/module-builder:latest \
--dry-run
# → outputs:
# /work/dist/manifest.json (parsed manifest)
# /work/dist/file-list.txt (files that would be included)
# /work/dist/package-list.txt (packages that would be installed)Verify against the platform's compatibility check (no upload):
platform.system_validate_module_manifest({
manifest_yaml: "<contents of manifest.yaml>",
category_slug: "userland"
})
// → { valid: true, warnings: [...], conflicts: [...] }This catches protected_spec collisions with existing modules in your account before you push.
Push your repo. The .gitea/workflows/build.yaml triggers on push:
# Two-stage build pipeline
on: [push]
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Build module artifact
run: |
# Canonical workflow uses buildah + mkfs.erofs, not docker build.
# See templates/module-repo/.gitea/workflows/build.yaml for the
# authoritative two-stage pipeline (buildah bud → rsync filter →
# mkfs.erofs → fs-verity → syft + grype → cosign sign → oras push).
buildah bud --layers --tag module-builder:${{ github.sha }} .
# ... composer stage runs mkfs.erofs + emits the artifact bundle ...
- name: Push to OCI registry
run: |
oras push registry.example.com/<account>/modules/my-nginx:${{ github.sha }} \
./dist/module.tar:application/vnd.powernode.module.v1+tar
- name: Sign with Cosign (static key)
run: |
cosign sign --key env://COSIGN_PRIVATE_KEY --yes registry.example.com/<account>/modules/my-nginx:${{ github.sha }}
env:
COSIGN_PRIVATE_KEY: ${{ secrets.POWERNODE_COSIGN_PRIVATE_KEY }}Do not copy the workflow inline — use the canonical version at templates/module-repo/.gitea/workflows/build.yaml. The example above sketches the shape; the canonical workflow handles the full two-stage build, SBOM/VEX generation, in-toto provenance attestations, and OCI referrers. Diverging from the canonical workflow risks composing modules that the platform's ModuleOciIngestService rejects.
What happens behind the scenes:
- Builder stage: mmdebstrap installs packages from
package_specinto a clean Debian rootfs - Composer stage: rsync applies your
rootfs/tree perfile_specrules; mkfs.erofs computes the fs-verity digest - Artifact emission: tar of the erofs lower layer + manifest.json (parsed) + erofs digest
- OCI push:
orasuploads the artifact toregistry.example.com - Cosign signing: static-key signing — Gitea Actions isn't on Sigstore Fulcio's trusted-issuer list, so keyless certs would never verify server-side. The
assemblejob signs withPOWERNODE_COSIGN_PRIVATE_KEY, a Gitea Actions secret you add to your repo (ask your platform operator for the value — it's the private half of the platform'sPOWERNODE_COSIGN_PUBLIC_KEY). Keyless/Fulcio signing only applies to modules actually built on a Fulcio-trusted CI (e.g. GitHub Actions), not this Gitea template.
The platform's ModuleOciIngestService polls the registry; when a new tag appears with a valid Cosign signature, it creates a NodeModuleVersion row in promotion_state: built. By default that means cosign verify against the platform's static POWERNODE_COSIGN_PUBLIC_KEY; the NodeModule's cosign_identity_regexp / cosign_issuer_regexp (set on the DB row, not the manifest) only come into play on the keyless fallback path, for modules signed by a genuinely Fulcio-trusted issuer.
platform.system_list_module_versions({ module_name: "my-nginx" })
// → { versions: [{ id, version_string, promotion_state: "built", composefs_digest, ... }] }The column is promotion_state (not lifecycle_state); valid states are built, staging, blessed, live, retired. built is the freshly-ingested state; promote through staging → blessed → live, demote/rollback to retired.
Promote through the lifecycle:
// built → staging (visible to operators; can be assigned to test instances)
platform.system_promote_module_version({ id: "<version-id>", to: "staging" })
// staging → blessed (passes operator review)
platform.system_promote_module_version({ id: "<version-id>", to: "blessed" })
// blessed → live (rolls out fleet-wide; gated by require_approval policy)
platform.system_promote_module_version({ id: "<version-id>", to: "live" })The module_promotion_sensor warns if a version has been in staging more than 24 h without operator action.
Templates compose modules into reusable bundles:
platform.system_assign_module_to_template({
template_id: "<template-id>",
module_name: "my-nginx",
// Optional metadata available to the agent at boot:
metadata: {
"purpose": "edge-cdn-tokyo"
}
})
// → { assignment: { id, template_id, module_id, priority, ... } }Priorities are determined by the module's category position + variety. The system_update_module_assignment MCP action toggles an assignment's enabled state, but not its priority — to override effective_priority (e.g., for a per-node config module that should win over a base subscription module), edit the assignment over REST:
# Bump effective_priority above userland (90) so a per-node config wins.
PATCH /api/v1/system/node_module_assignments/<assignment-id>
{ "effective_priority": 95 }To change which modules a template carries, use the assign/unassign MCP actions (system_assign_module_to_template / system_unassign_module_from_template) rather than an update call.
Once assigned, every NodeInstance built from this template will pull the module on its next reconcile tick. Use system_drift_report to verify.
variety and parent_module are platform-side NodeModule fields, not
manifest keys — set them when you register the module (UI or
system_create_module_from_package). The manifest itself stays flat and only
contributes the override file:
schema_version: 1
name: nginx-tokyo-config
display_name: "nginx Tokyo overrides"
# This module *only* contributes file_spec — no packages, no erofs lower.
file_spec:
- "/etc/nginx/conf.d/99-tokyo.conf"Register this module with variety: instance on its platform-side row (higher
effective_priority than a subscription module). The manifest stays flat:
schema_version: 1
name: hostname-override
display_name: "Per-instance hostname"
# Templates evaluated per-NodeInstance with metadata bindings.
file_spec:
- "/etc/hostname"
- "/etc/hosts"
# The module-builder substitutes ${instance.hostname} from NodeInstance metadata.Register with variety: config + parent_module: chrony on the platform-side
row. file_spec and mask are flat top-level arrays in the manifest:
schema_version: 1
name: chrony-no-pool
display_name: "chrony without pool directives"
file_spec:
- "/etc/chrony/chrony.conf"
mask:
- "/etc/chrony/chrony.conf" # carve out parent's protected_spec ownershipThe mask directive is a deliberate escape hatch — use sparingly; it inverts the safety guarantee of protected_spec.
| Symptom | Cause | Fix |
|---|---|---|
ModuleManifestSchemaError on push |
YAML doesn't match schema_version | Run platform.system_validate_module_manifest locally first |
| Cosign signature rejected | Static-key mismatch (default path) — repo's POWERNODE_COSIGN_PRIVATE_KEY doesn't correspond to the platform's POWERNODE_COSIGN_PUBLIC_KEY; only the keyless fallback checks cosign_identity_regexp/cosign_issuer_regexp |
Confirm the repo's cosign key secret with your platform operator; for the keyless fallback, verify the signing CI's OIDC issuer matches your regexp |
Module shows in registry but no NodeModuleVersion row |
OCI ingest hasn't run yet | Wait 60 s for the next ingest poll; check journalctl -u 'powernode-*-sidekiq.service' | grep ModuleOciIngest |
protected_spec collision on assignment |
Another module owns one of your protected files | Rename your file or use mask in a config-variety override |
| Assignment to template succeeds but agent doesn't pull | Module is still built promotion_state — agents only pull blessed+ |
Promote: system_promote_module_version |
| fs-verity digest mismatch on agent | Module artifact corrupted during transit | Re-run CI build; the platform re-ingests on next OCI poll |
When an operator chats "I need a new module for X" / "compose a template for nginx + TLS":
- Use
module_composeskill — keyword-matches existing modules + drafts a Template - If a custom module is needed, surface this runbook + the
templates/module-repo/skeleton - For assignment workflows, use
system_assign_module_to_templatewithrequest_confirmation
templates/module-repo/README.md— skeleton this runbook expands ontemplates/example-modules/— 5 working examples (apache, chrony, nginx, rpi4-firmware, security-hardening)USE_CASE_MATRIX.md— composition use cases (long-lived edge, multi-tenant, per-tenant config)SKILL_EXECUTORS.md—module_composeskill for AI-assisted compositionrunbooks/cve-response.md— module updates triggered by CVE responseDISK_IMAGE_CI.md— companion pipeline for base disk images (vs. modules)
Last verified: 2026-06-03