Skip to content

Latest commit

 

History

History
395 lines (299 loc) · 20.3 KB

File metadata and controls

395 lines (299 loc) · 20.3 KB

Module Authoring Runbook

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.

Phase 0 — Should this be a module at all?

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 reference

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

Phase 1 — Set up the module repo ✅

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.

Phase 2 — Author manifest.yaml ✅

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's name is the stable identifier; downstream tooling looks up the NodeModule row by it.
  • package_spec — apt packages installed in the Containerfile builder. Applied via mmdebstrap to the rootfs.
  • file_specflat 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/shadow from 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.

Phase 3 — Author Containerfile + rootfs ✅

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.

Phase 4 — Local test (dry-run build) ✅

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.

Phase 5 — Push to Gitea + CI build ✅

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:

  1. Builder stage: mmdebstrap installs packages from package_spec into a clean Debian rootfs
  2. Composer stage: rsync applies your rootfs/ tree per file_spec rules; mkfs.erofs computes the fs-verity digest
  3. Artifact emission: tar of the erofs lower layer + manifest.json (parsed) + erofs digest
  4. OCI push: oras uploads the artifact to registry.example.com
  5. 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 assemble job signs with POWERNODE_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's POWERNODE_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.

Phase 6 — Verify publication ✅

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.

Phase 7 — Assign to a Template ✅

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.

Common manifest patterns

Override a base module's config (variety: config)

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"

Per-instance customization (variety: instance)

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.

Mask a parent module's protected file (carve-out)

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 ownership

The mask directive is a deliberate escape hatch — use sparingly; it inverts the safety guarantee of protected_spec.

Troubleshooting

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

How the System Concierge should use this

When an operator chats "I need a new module for X" / "compose a template for nginx + TLS":

  1. Use module_compose skill — keyword-matches existing modules + drafts a Template
  2. If a custom module is needed, surface this runbook + the templates/module-repo/ skeleton
  3. For assignment workflows, use system_assign_module_to_template with request_confirmation

Related docs

Last verified: 2026-06-03