Skip to content

Latest commit

 

History

History
426 lines (338 loc) · 19.8 KB

02-first-module.md

File metadata and controls

426 lines (338 loc) · 19.8 KB

Tutorial 02 — Your first custom module

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).

What you're building

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
Loading

By the end you'll have published my-redis as a versioned, signed, lifecycle-tracked module assignable to any NodeTemplate.

Concept refresher

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 layer
  • Containerfile — 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.

Prerequisites

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

Step 1 — Clone the canonical template

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.git

Expected 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.

Step 2 — Edit manifest.yaml

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 default

Expected 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.

Step 3 — Add the rootfs tree

mkdir -p rootfs/etc/redis rootfs/var/lib/redis
touch rootfs/var/lib/redis/.gitkeep

Write 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/redis

Expected 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.

Step 4 — Validate the manifest locally

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-errors

This 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.

Step 5 — Register the module on the platform

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 the name in your manifest.yaml
  • display_name, description
  • category_id — pick from the seeded NodeModuleCategory list (system-base, network-overlay, container-runtimes, security-hardening, userland)
  • variety — subscription / config / instance
  • gitea_repo_full_name — e.g. <account>/modules/my-redis-module
  • cosign_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.

Step 6 — Push to Gitea

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 --tags

Expected 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", ... }] }

Step 7 — Wait for CI

The workflow runs:

  1. Stage 1 (Containerfile build) — pulls the Ubuntu 24.04 base at the pinned digest, installs package_spec, copies rootfs/ to /work/
  2. Stage 2 (erofs encode) — converts /work/ to a content-addressed erofs blob with fs-verity root hash
  3. syft + grype — generates SBOM + VEX; the SBOM is ingested by the platform's CVE pipeline
  4. cosign sign (static key) — Gitea Actions isn't on Sigstore Fulcio's trusted-issuer list, so the assemble job signs with POWERNODE_COSIGN_PRIVATE_KEY (the repo secret from Step 5) instead of a keyless/OIDC-bound cert
  5. oras push — pushes to registry.example.com/<account>/modules/my-redis-module:v0.1.0
  6. Webhook — POSTs to platform's /api/v1/system/webhooks/gitea/module with HMAC signed by POWERNODE_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.

Step 8 — Verify ingestion

// 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.

Step 9 — Assign to a Template + provision

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 reconcile

Expected outcome: instance reaches status: running and the module appears in running_module_digests.

Verification

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
# → PONG

Cleanup

Leaves 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 versions

Troubleshooting

Workflow 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_SECRET doesn't match NodeModule.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_spec references a module that's not in staging+)
  • 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_audit line, 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.

What's next

Last verified: 2026-08-04