Status: active
What you'll learn: Author, sign, publish, and assign a custom NodeModule — the full module supply chain from blank git repo to a Module showing up on a NodeInstance.
Time: ~45 min (most of which is waiting for CI)
Builds on: Tutorial 01 — needs the catalog seeded and the platform running. The VM from Tutorial 01 doesn't need to be live for this tutorial; we'll provision a fresh one in step 9.
Sets you up for: Tutorial 03 — Docker runtime (uses a real module —
docker-engine— to provision Docker; the workflow you learn here is how you'd author your own runtime variant).
flowchart LR
Op[Operator]
subgraph Repo["Gitea repo (your module source)"]
Mf[manifest.yaml]
Rfs[rootfs/]
Cf[Containerfile]
Wf[.gitea/workflows/build.yaml]
end
subgraph CI["Gitea Actions"]
Build[Stage 1<br/>Containerfile build]
Compose[Stage 2<br/>erofs encode]
Sign[cosign sign<br/>static key]
end
Reg[(OCI registry<br/>registry.example.com)]
subgraph Plat["Powernode platform"]
Ingest[ModuleOciIngestService]
Ver[NodeModuleVersion]
Tpl[NodeTemplate]
end
VM[NodeInstance]
Op --> Repo
Repo -- "git push tag v0.1.0" --> CI
Build --> Compose --> Sign
Sign -- "oras push + cosign attest" --> Reg
CI -- "webhook back" --> Ingest
Ingest -- "creates" --> Ver
Op -- "system_assign_module_to_template" --> Tpl
Tpl -- "next reconcile" --> VM
By the end you'll have published my-redis as a versioned, signed, lifecycle-tracked module assignable to any NodeTemplate.
A NodeModule is a versioned, signed unit of filesystem + package state. Each module ships:
manifest.yaml— authoring-time hints (name, license, packages, file globs)rootfs/— files copied verbatim into the module's filesystem layerContainerfile— Stage 1 of CI; installs packages + copies rootfs
The platform composes a NodeInstance's root filesystem from priority-ordered module erofs layers (see Tutorial 01 overlay-union diagram). Each layer is content-addressed, fs-verity-hashed, cosign-signed — tamper-detected at file-open time.
Authority note: the four glob-spec fields (mask, file_spec,
package_spec, dependency_spec) on the System::NodeModule platform
record are authoritative for builds. The repo's manifest.yaml only seeds
those fields on first import; subsequent edits happen via the platform UI / API.
| Requirement | How |
|---|---|
| Tutorial 01 completed | Catalog seeded |
| Gitea account with permission to create repos | E.g. registry.example.com/<account>/modules/... |
docker + oras + cosign CLIs |
apt install docker.io; curl -L <release>/oras_*.tar.gz | tar xz; curl -L <release>/cosign-linux-amd64 -o ~/.local/bin/cosign && chmod +x ~/.local/bin/cosign |
Gitea Actions runner labeled ubuntu-24.04 |
(self-hosted; multi-arch needs ubuntu-24.04-arm too) |
| A NodeTemplate to assign your module to | Use base or hardened from Tutorial 01's catalog |
git clone git@registry.example.com:powernode/templates/module-repo.git my-redis-module
cd my-redis-module
rm -rf .git
git init
git remote add origin git@registry.example.com:<account>/modules/my-redis-module.gitExpected outcome: clean working tree with Containerfile,
manifest.yaml, rootfs/.gitkeep, and .gitea/workflows/build.yaml. The
template is the canonical layout — never deviate from it; the platform's
ingest path expects exactly this shape.
schema_version: 1
# Identity — flat top-level keys (no `identity:` wrapper).
name: my-redis
display_name: "Redis 7.4"
description: "Redis 7.4 with TLS + persistence"
license: "BSD-3-Clause"
# Packages installed into the rootfs by the builder stage (mmdebstrap).
package_spec:
- redis-server
- redis-tools
# Paths this module OWNS in the artifact (rsync-include, flat glob list).
file_spec:
- "/etc/redis/**"
- "/var/lib/redis/.gitkeep"
# Paths to EXCLUDE from this module's blob (rsync-style mask, applied locally).
mask:
- "/etc/redis/sentinel.conf"
# Paths I own that no neighbor module may ship — folded into every
# neighbor's effective_mask in both priority directions, so this module's
# /etc/redis/redis.conf cannot be silently overridden by a higher-priority
# module's overlay.
protected_spec:
- "/etc/redis/redis.conf"
# Module dependencies — resolved transitively by DependencyResolutionService.
# Two `requires` forms: a name pin "<owner>/<module>@<version-constraint>"
# (used below), which binds to one specific module by repo or name; or a
# capability requirement "capability:<tag>[@<constraint>]" (e.g.
# "capability:database.postgres@>= 16"), which binds to whichever module on
# the account advertises that tag — see the module-authoring runbook's
# "The two `requires` forms" section for the full syntax + gotchas.
dependencies:
requires:
- "powernode/runtime-ruby@^1.0"
- "powernode/security-hardening@^1.0"
provides: []
# Init lifecycle. Strings here populate NodeModule.init_start/stop/restart;
# powernode-agent runs them as subprocesses (never eval'd).
init:
start: "systemctl start redis-server"
stop: "systemctl stop redis-server"
restart: "systemctl restart redis-server"
reboot_required: false
# Optional build hints (pin Ubuntu base + apt snapshot for reproducibility).
build:
ubuntu_digest: null # falls back to Containerfile's UBUNTU_DIGEST default
apt_snapshot: null # falls back to Containerfile's APT_SNAPSHOT defaultExpected outcome: YAML validates against MODULE_MANIFEST_COMPLETE_SCHEMA.md and modules/.schema/module-manifest.schema.json.
Important: category, variety, cosign_identity_regexp, and cosign_issuer_regexp are NOT manifest fields. They live on the platform-side NodeModule DB row and are set at registration time (Step 5 below — via the UI form or the registration MCP action). The cosign identity regex must match exactly the path your Gitea Actions runs from — mismatches cause ingestion to reject the artifact post-build.
mkdir -p rootfs/etc/redis rootfs/var/lib/redis
touch rootfs/var/lib/redis/.gitkeepWrite rootfs/etc/redis/redis.conf:
bind 0.0.0.0 ::
port 6379
protected-mode yes
tls-port 6380
tls-cert-file /etc/redis/tls/server.crt
tls-key-file /etc/redis/tls/server.key
tls-ca-cert-file /etc/redis/tls/ca.crt
appendonly yes
dir /var/lib/redisExpected outcome: Files under rootfs/ will be copied verbatim into the
module's erofs layer at build time. .gitkeep is the convention for
empty directories — erofs needs the dir to exist in the artifact, even
empty.
Schema-validate against the JSON schema first — this works offline, before the module is even registered on the platform:
yq -o=json '.' manifest.yaml > /tmp/manifest.json
npx --yes ajv-cli@5 validate \
-s <path-to-checkout>/modules/.schema/module-manifest.schema.json \
-d /tmp/manifest.json \
--spec=draft2020 --all-errorsThis is the same check module-validate.yaml
runs at PR time — running it locally first just fails faster.
Once the module is registered (Step 5 below gives you its module_id), validate
subsequent manifest revisions against the platform's compatibility check — it
additionally catches protected_spec collisions with existing modules in your
account:
platform.system_validate_module_manifest({
module_id: "<the NodeModule id from Step 5>",
manifest_yaml: "<contents of manifest.yaml>"
})
// → { valid: true, validation_errors: [] } | { valid: false, error: "...", validation_errors: [...] }Expected outcome: either check reports schema violations, glob-spec conflicts, or missing required fields without building artifacts. Fix warnings here — they'll fail later much more expensively.
Use the operator UI today. Navigate to /app/system/catalog/modules (Catalog ▸ Modules) in your platform UI and use its "Create Module" action — it opens a form modal in place; there is no separate /new route. The form collects:
name— must match thenamein yourmanifest.yamldisplay_name,descriptioncategory_id— pick from the seeded NodeModuleCategory list (system-base,network-overlay,container-runtimes,security-hardening,userland)variety—subscription/config/instancegitea_repo_full_name— e.g.<account>/modules/my-redis-modulecosign_identity_regexp+cosign_issuer_regexp— set on the DB row (NOT in manifest)
On save, the UI returns a webhook_secret. Copy it immediately — it's displayed once and used to HMAC-sign the build-completion webhook back from Gitea Actions to the platform.
In Gitea: open repo → Settings → Actions → Secrets → add POWERNODE_WEBHOOK_SECRET with the value from above.
Also add the cosign signing secret. The canonical workflow signs with a static cosign key rather than keyless/Fulcio (Gitea Actions isn't on Sigstore Fulcio's trusted-issuer list — see Step 7). Ask your platform operator for the repo's POWERNODE_COSIGN_PRIVATE_KEY (and POWERNODE_COSIGN_KEY_PASSWORD if the key is password-protected) — the private half of the platform's POWERNODE_COSIGN_PUBLIC_KEY — and add both as Actions secrets alongside POWERNODE_WEBHOOK_SECRET. Skipping this pushes an unsigned index; the workflow warns but doesn't fail, and the platform's cosign verify rejects it later.
Note on MCP: system_create_module_from_package does exist as an MCP action but materialises a module from an existing PackageRepository (signature: repository_id, package_name, architectures, recommends_selected, category_id, dispatch_build). It is not a Gitea-repo-to-NodeModule registration shortcut. The Gitea-driven flow uses the UI form today; a dedicated MCP wrapper for it may land later.
git add manifest.yaml Containerfile rootfs/ .gitea/
git commit -m "feat: my-redis module v0.1.0"
git tag v0.1.0
git push origin develop --tagsExpected outcome: tag push triggers the workflow in .gitea/workflows/build.yaml.
Watch via MCP:
platform.list_gitea_workflow_runs({
owner: "<account>",
repo: "modules/my-redis-module"
})
// → { runs: [{ id, status: "in_progress", ... }] }The workflow runs:
- Stage 1 (Containerfile build) — pulls the Ubuntu 24.04 base at the
pinned digest, installs
package_spec, copiesrootfs/to/work/ - Stage 2 (erofs encode) — converts
/work/to a content-addressed erofs blob with fs-verity root hash - syft + grype — generates SBOM + VEX; the SBOM is ingested by the platform's CVE pipeline
- cosign sign (static key) — Gitea Actions isn't on Sigstore Fulcio's
trusted-issuer list, so the
assemblejob signs withPOWERNODE_COSIGN_PRIVATE_KEY(the repo secret from Step 5) instead of a keyless/OIDC-bound cert oras push— pushes toregistry.example.com/<account>/modules/my-redis-module:v0.1.0- Webhook — POSTs to platform's
/api/v1/system/webhooks/gitea/modulewith HMAC signed byPOWERNODE_WEBHOOK_SECRET
Expected outcome: ~5–8 min runtime. Workflow shows success. Step 6's
webhook triggers System::ModulePublicationProcessor, which calls
ModuleOciIngestService.ingest! — there is no polling — and creates a
NodeModuleVersion row. If the row is missing,
check the repo's Gitea webhook deliveries and redeliver rather than waiting.
// Module verbs take the module's UUID, never its name — system_list_modules
// returns { id, name, ... } for the account's catalog.
platform.system_list_module_versions({ module_id: "<my-redis-module-id>" })
// → { versions: [{
// id: "0199c8f1-...", // UUIDv7, not a "v-<name>-<semver>" string
// module_id: "0199c8ee-...",
// version_number: 1, // integer, not a semver string
// current: false, // whether the FOLLOWING planes serve this row
// pinned_in: [], // the PINNED planes serving it (staging, prod, ...)
// oci_digest: "sha256:abc...",
// fsverity_root_hash: "sha256:def..."
// }] }Expected outcome: the version row exists. This response carries no signature field, so it is not where you confirm signing — check the ModuleArtifact rows / the build-batch surface instead. (Do not infer verification from the row existing: ingest_native! is unsigned by design, and outside production ModuleOciIngestService defaults to LocalOciAdapter, which reports every signature as verified.)
A version row carries no lifecycle label. What runs where is a fact about each ENVIRONMENT: dev, ci and ops FOLLOW publishes (they serve current: true), while staging and prod are PINNED and serve only what was promoted into them. So the ladder you walk is made of planes:
// One rung at a time. staging is the bottom pinned rung, so it takes the
// version the following planes already run.
platform.system_promote_module_version({ module_id: "<module-id>", environment: "staging" })
// Verified in staging? Promote the same version onward. prod is seeded
// supervised, so this PARKS for an operator rather than promoting.
platform.system_promote_module_version({ module_id: "<module-id>", environment: "prod", version_id: "<version-id>" })A promotion moves that one plane's pin and nothing else. It does not change which version the FOLLOWING planes serve: they read NodeModule#current_version_id, which the promotion path never writes. Step 7's webhook is what moves it: gitea_module#handle runs System::ModulePublicationProcessor, which writes current_version_id by default. It withholds that write when the module sets auto_promote false or when the erofs layer is under the non-empty floor, emitting a high-severity system.module_promotion_withheld event naming the reason. (A third condition, a System::CoreProvenanceGate refusal, applies only to native builds — the gate is inert on this path.) system_list_module_versions marks the row the following planes serve current: true and names the pinned planes under pinned_in; if no row carries current, repoint with system_rollback_module_version({ module_id, version_id }), which moves the pointer forward as well as back — though it refuses a target below that same floor. See module-authoring.md for the full decision tree.
Promoting into a protected plane is require_approval — check the module_promote_to_live intervention policy. To walk one pinned plane back, use system_rollback_module_version with environment: (downward only); to repoint the fleet, use it without.
platform.system_assign_module_to_template({
template_id: "<base-or-hardened-template-id>",
module_id: "<my-redis-module-id>"
})
// Provision a fresh instance from that template
platform.system_create_node({ name: "redis-test-1", template_id: "<template-id>" })
platform.system_provision_instance({
node_id: "<node-id>",
provider_region_id: "<region-id>", // required — provisioning errors without it
provider_instance_type_id: "<instance-type-id>"
})
// Wait ~3-5 min for KVM boot + module reconcileExpected outcome: instance reaches status: running and the module
appears in running_module_digests.
platform.system_get_instance({ instance_id: "<instance-id>" })
// → { instance: {
// // keyed by node_module_id (UUID), NOT by module name — the agent
// // reports module_digests as node_module_id -> oci_digest
// running_module_digests: { "0199c8ee-...": "sha256:abc...", ... },
// ...
// }}
platform.system_drift_report({ instance_id: "<id>" })
// → { drift: false }If you can reach the instance over SDWAN (set up during Tutorial 01):
ssh ops@<instance-host-address>
systemctl status redis-server.service
# → active (running)
redis-cli ping
# → PONGLeaves the catalog seeded with my-redis for future reference, but
removes the test instance:
platform.system_terminate_instance({ instance_id: "<instance-id>" })
// Unassign so the next instance from this template doesn't get the test module
platform.system_unassign_module_from_template({
template_id: "<template-id>",
module_id: "<my-redis-module-id>"
})
// (Optional) archive the module if you don't want it visible in the catalog
platform.system_delete_module({ module_id: "<my-redis-module-id>" }) // cascade-deletes versionsWorkflow fails at the cosign sign step (bad key/password error) —
POWERNODE_COSIGN_PRIVATE_KEY (or POWERNODE_COSIGN_KEY_PASSWORD) doesn't
match what your platform operator provisioned. Re-copy both secrets exactly
(watch for a trailing newline pasted into the Gitea Actions secret box) and
re-trigger. If the secrets are entirely absent, the step logs a [warn] and
pushes an unsigned index instead of failing — see the next entry.
Workflow succeeds but no NodeModuleVersion row appears — webhook failed to authenticate. Two common causes:
POWERNODE_WEBHOOK_SECRETdoesn't matchNodeModule.webhook_secret(regenerate via Settings → Actions → Secrets and re-paste)- Platform's webhook controller IP-banned the Gitea runner (check
journalctl -u 'powernode-*-rails.service' | grep gitea_module)
cosign_verified: false in version row — on the default static-key path
this means the repo's POWERNODE_COSIGN_PRIVATE_KEY doesn't correspond to
the platform's POWERNODE_COSIGN_PUBLIC_KEY (or the secret was missing, so
the index pushed unsigned). Confirm the key pair with your platform operator
and re-trigger. Only the keyless fallback path checks the identity/issuer
regex — irrelevant unless you've deliberately switched off static-key
verification.
Instance reconciles but module doesn't appear in running_module_digests —
agent's heartbeat is reporting a different list than what's assigned. Two
sub-cases:
- Module dependency missing on the platform side (your
dependency_specreferences a module that's not instaging+) - Agent failed the blob sha256 digest check on download (check agent logs via serial console; look for "digest mismatch"). Note fs-verity never blocks a mount: under the default module-signing mode it is not checked, and under any other mode a problem is only reported as a
verify:module_fsverity_auditline, so it cannot be why a module is missing
PoolEmptyError on provision — you're using an InstancePool template but
the pool's empty. Either wait for replenishment (~5 min) or create a fresh
non-pool NodeInstance.
- Tutorial 03 — Container runtime — Docker —
the
docker-enginemodule is just a module like the one you built. Now you'll assign it to a node and watch the platform managedockerd. runbooks/module-authoring.md— full reference: variety types, dependent module hierarchies, config overlays, advanced glob semantics.templates/example-modules/— working examples (apache, chrony, nginx, rpi4-firmware, security-hardening) you can study for patterns.MODULE_MANIFEST_COMPLETE_SCHEMA.md— every manifest field explained.
Last verified: 2026-08-04