Back to feed

sledtools/pika branch #149

pika-cloud-arbitrary-guest-config-spike

Introduce Incus guest roles for image configs

Target branch: master

Merge Commit: 4f2766d4311757201bc52947b98ec45e4de8e1c8

branch: merged tutorial: ready ci: failed
Open CI Details

Continuous Integration

CI: failed

Compact status on the review page, with full logs on the CI page.

Open CI Details

Latest run #186 failed

9 passed 1 failed

head 4eef499fd13da5aec04714b080cc05fdcf655bbc · queued 2026-03-27 16:01:08 · 10 lane(s)

queued 8s · ran 2m 43s

check-pika-rust · success check-pika-followup · success check-notifications · success check-agent-contracts · success check-rmp · success check-pikachat · success check-pikachat-typescript · success check-apple-host-sanity · failed check-pikachat-openclaw-e2e · success check-fixture · success

Summary

This branch introduces a first-class IncusGuestRole enum to the Pika codebase, replacing hard-coded image aliases and duplicated Nix image-build logic with a role-driven abstraction. A new pika-incus-guest-role crate defines two roles — managed-openclaw (the server-managed OpenClaw agent) and pikaci-runner (the CI runner) — each carrying its own default image alias and Nix flake package attribute. The role propagates through the managed-agent contract, the CI executor, and the pika-server agent API so that image selection, instance config, and provisioning parameters are all derived from the role rather than from scattered string constants. On the Nix side, duplicated image-packaging expressions are collapsed into a reusable guest-image-package.nix builder driven by a roles.nix registry, and the shell scripts are unified into a single incus-role-image.sh dispatcher. Legacy image-alias package names are preserved as aliases for backward compatibility.

Tutorial Steps

Create the `pika-incus-guest-role` crate

Intent: Define a shared enum that every crate can use to identify Incus guest roles, with serde support, `FromStr` parsing, and methods that map each role to its canonical image alias and Nix flake package attribute.

Affected files: crates/pika-incus-guest-role/Cargo.toml, crates/pika-incus-guest-role/src/lib.rs, Cargo.toml, Cargo.lock

Evidence
@@ -0,0 +1,11 @@ [package] name = "pika-incus-guest-role"
@@ -0,0 +1,101 @@ use serde::{Deserialize, Serialize}; ... pub enum IncusGuestRole { ManagedOpenclaw, PikaciRunner, }
@@ -8,6 +8,7 @@ members = [ ... + "crates/pika-incus-guest-role",
@@ -36,6 +37,7 @@ ... +pika-incus-guest-role = { path = "crates/pika-incus-guest-role" }

A new workspace crate pika-incus-guest-role is added with zero external dependencies beyond serde/serde_json.

The central type is:

#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum IncusGuestRole {
    ManagedOpenclaw,
    PikaciRunner,
}

Key helpers:

  • as_str() — canonical kebab-case identifier (managed-openclaw, pikaci-runner).
  • default_image_alias() — the Incus alias each role resolves to when no override is given (pika-agent/dev, pikaci/dev).
  • uses_default_image_alias(alias) — returns true only when the active alias matches the role default (used later to decide whether to record the role in metadata).
  • flake_package_attr() — the Nix flake package name for the image.
  • FromStr — strict parse that rejects unknown strings.

Unit tests pin serde round-trip, alias mapping, and whitespace tolerance.

Wire the guest role into `pika-managed-agent-contract`

Intent: Expose the role through the managed-agent provisioning contract so callers (CLI, server) can request a specific guest role when provisioning an agent.

Affected files: crates/pika-managed-agent-contract/Cargo.toml, crates/pika-managed-agent-contract/src/lib.rs

Evidence
@@ -4,5 +4,6 @@ ... +pika-incus-guest-role = { workspace = true }
@@ -1,3 +1,5 @@ +pub use pika_incus_guest_role::IncusGuestRole;
@@ -14,6 +16,8 @@ pub struct IncusProvisionParams { + #[serde(skip_serializing_if = "Option::is_none")] + pub guest_role: Option<IncusGuestRole>,

The pika-managed-agent-contract crate re-exports IncusGuestRole and adds an optional guest_role field to IncusProvisionParams. The field is Option<IncusGuestRole> and serializes only when Some, preserving backward compatibility with existing JSON payloads.

The existing round-trip test is updated to set guest_role: Some(IncusGuestRole::ManagedOpenclaw).

Integrate the guest role into the `jerichoci` CI executor

Intent: Let the CI executor derive image aliases from the guest role instead of a hard-coded default, and record the role in execution metadata for traceability.

Affected files: crates/jerichoci/Cargo.toml, crates/jerichoci/src/executor.rs, crates/jerichoci/src/executor/incus.rs, crates/jerichoci/src/model.rs, crates/jerichoci/src/run.rs

Evidence
@@ -81,6 +82,7 @@ struct RemoteIncusContext { + incus_guest_role: IncusGuestRole,
@@ -100,6 +102,14 @@ impl RemoteIncusContext { fn recorded_guest_role(&self) -> Option<IncusGuestRole> { self.incus_guest_role .uses_default_image_alias(&self.incus_image_alias) .then_some(self.incus_guest_role) }
@@ -1855,9 +1867,34 @@ fn remote_linux_vm_incus_image_alias ... +fn remote_linux_vm_incus_guest_role(job: &JobSpec) -> anyhow::Result<IncusGuestRole> {
@@ -1738,6 +1794,8 @@ pub struct RemoteLinuxVmImageRecord { + #[serde(default, skip_serializing_if = "Option::is_none", alias = "role")] + pub guest_role: Option<IncusGuestRole>,

Several interconnected changes propagate the role through CI pipelines:

  1. IncusRuntimeConfig gains a guest_role: IncusGuestRole field, set to PikaciRunner for all CI targets (crates/pikaci/src/targets.rs).

  2. RemoteIncusContext stores the role and exposes recorded_guest_role(), which returns Some(role) only when the image alias hasn't been overridden (custom aliases suppress the role to avoid misattribution).

  3. remote_linux_vm_incus_guest_role() resolves the role from either the PIKACI_REMOTE_LINUX_VM_INCUS_GUEST_ROLE env var or the job's JobRuntimeConfig::Incus config. An invalid env value produces a clear error.

  4. remote_linux_vm_incus_image_alias() now accepts a guest_role parameter and falls back to guest_role.default_image_alias() instead of the old REMOTE_LINUX_VM_INCUS_IMAGE_ALIAS_DEFAULT constant (which is deleted).

  5. RemoteLinuxVmImageRecord gains an optional guest_role field with #[serde(alias = "role")] for forward compatibility.

  6. load_remote_incus_image_record() populates guest_role via recorded_guest_role().

New tests cover: canonical vs. custom alias recording, invalid env rejection, and legacy JSON deserialization with/without the guest_role key (including the role alias).

Propagate the guest role through pika-server's agent API

Intent: Let the server resolve, validate, merge, and materialize the guest role when provisioning managed Incus agents, and stamp it into the instance config.

Affected files: crates/pika-server/Cargo.toml, crates/pika-server/src/agent_api.rs, crates/pika-server/src/managed_runtime_contract.rs

Evidence
@@ -222,6 +226,7 @@ struct ResolvedIncusParams { + guest_role: IncusGuestRole,
@@ -377,6 +382,7 @@ fn materialized_managed_runtime_params ... + guest_role: materialized_guest_role(resolved),
@@ -422,6 +435,10 @@ fn merge_incus_provision_params ... + if requested.guest_role.is_some() { merged.guest_role = requested.guest_role; changed = true; }
@@ -3358,7 +3396,20 @@ fn resolved_incus_params ... + let guest_role = params.guest_role.unwrap_or(IncusGuestRole::ManagedOpenclaw); + anyhow::ensure!( guest_role == IncusGuestRole::ManagedOpenclaw, "managed runtime currently only supports incus.guest_role = managed-openclaw" );
@@ -1610,6 +1639,12 @@ ... + if let Some(guest_role) = materialized_guest_role(&self.resolved) { instance_config.insert( INCUS_GUEST_ROLE_CONFIG_KEY ... guest_role.as_str() ... ); }

On the server side:

  1. ResolvedIncusParams adds a non-optional guest_role: IncusGuestRole field.

  2. default_incus_params_from_env() now returns anyhow::Result and reads PIKA_AGENT_INCUS_GUEST_ROLE via the new incus_guest_role_from_env() helper. Invalid values produce an actionable error message.

  3. resolved_incus_params() merges the guest role from env defaults and the caller's request, then asserts guest_role == ManagedOpenclaw (the only role the managed runtime currently supports). The image alias falls back to guest_role.default_image_alias() instead of requiring it from the env.

  4. materialized_guest_role() mirrors the CI pattern — the role is included in materialized params only when the image alias matches the role's default.

  5. merge_incus_provision_params() and incus_params_provided() now account for the guest_role field.

  6. Instance creation stamps user.pika.guest_role into the Incus instance config when a role is materialized.

  7. agent_api_healthcheck() propagates the ? from the now-fallible default_incus_params_from_env().

New tests verify: role preservation in materialized params, role omission for custom aliases, and rejection of invalid env values.

Update the CLI to pass `guest_role: None`

Intent: Keep the CLI compiling after `IncusProvisionParams` gained the new field, without changing CLI behavior.

Affected files: cli/src/main.rs

Evidence
@@ -1751,6 +1751,7 @@ fn agent_provision_request ... + guest_role: None,
@@ -2880,6 +2881,7 @@ mod tests { ... + guest_role: None,

The CLI does not yet expose a way to select a guest role, so guest_role is set to None in both the production path and the test fixture. This means the server will apply its own default (currently ManagedOpenclaw).

Refactor Nix image builds into a role-driven registry

Intent: Eliminate duplicated NixOS image-build expressions by extracting a reusable builder function and a declarative role registry.

Affected files: nix/incus/guest-image-package.nix, nix/incus/roles.nix, flake.nix

Evidence
@@ -0,0 +1,59 @@ { nixpkgsLib, serverPkgs }: ... let imageSystem = nixpkgsLib.nixosSystem { inherit system modules specialArgs; }; imageDisk = import ...
@@ -0,0 +1,33 @@ { openclawGatewayPkg, pikachatPkg }: { "managed-openclaw" = { id = "managed-openclaw"; ... }; "pikaci-runner" = { id = "pikaci-runner"; ... }; }
@@ -320,104 +320,30 @@ ... + mkIncusGuestImagePkg = import ./nix/incus/guest-image-package.nix { inherit serverPkgs; nixpkgsLib = nixpkgs.lib; }; + incusGuestImageRoles = import ./nix/incus/roles.nix { inherit openclawGatewayPkg pikachatPkg; }; + mkIncusGuestRoleImagePkg = role: mkIncusGuestImagePkg { ... };

Previously, flake.nix contained two nearly identical 40-line blocks to build the managed-agent and CI runner Incus images. This branch replaces them with:

  1. nix/incus/guest-image-package.nix — A function that takes { system, name, packageName, description, variant, diskSize, modules, specialArgs, buildInstructions, importInstructions } and produces a derivation containing metadata.tar.xz, disk.qcow2, and a README.txt.

  2. nix/incus/roles.nix — A declarative attrset mapping role IDs to their image parameters (system, package attr, default alias, disk size, NixOS modules, etc.).

  3. flake.nix — Defines mkIncusGuestRoleImagePkg which composes the two, then exports:

    • managed-openclaw-incus-image and pikaci-runner-incus-image as the canonical package names.
    • pika-agent-incus-dev-image and pikaci-incus-dev-image as backward-compatible aliases pointing to the same derivations.

Unify shell scripts into `incus-role-image.sh`

Intent: Replace two nearly identical image-management shell scripts with a single role-aware dispatcher, and turn the old scripts into thin wrappers.

Affected files: scripts/incus-role-image.sh, scripts/incus-dev-image.sh, scripts/pikaci-incus-image.sh

Evidence
@@ -0,0 +1,178 @@ #!/usr/bin/env bash ... role="" ... resolve_role() { case "$role" in managed-openclaw) alias_name="${alias_name:-pika-agent/dev}" package_attr="managed-openclaw-incus-image" ;; pikaci-runner) ...
@@ -1,142 +1,4 @@ #!/usr/bin/env bash set -euo pipefail exec "$(cd "$(dirname "$0")" && pwd)/incus-role-image.sh" --role managed-openclaw "$@"

A new scripts/incus-role-image.sh accepts --role ROLE before the subcommand and resolves the alias and Nix package attribute from the role name. It supports build, import, and build-import subcommands, same as before.

Notable improvement: build-import now uses git ls-files -co --exclude-standard -z | tar instead of git archive, which includes untracked (but non-ignored) files — useful when building from a dirty working tree.

The legacy scripts incus-dev-image.sh and pikaci-incus-image.sh become one-liner exec wrappers that delegate to the new script with the appropriate --role flag.

Update NixOS server deployment config

Intent: Switch the production pika-server host from a hard-coded image alias to a guest role, and make `incusImageAlias` optional in the module.

Affected files: infra/nix/hosts/pika-server.nix, infra/nix/modules/pika-server.nix

Evidence
@@ -10,7 +10,7 @@ ... - incusImageAlias = "pika-agent/dev"; + incusGuestRole = "managed-openclaw";
@@ -6,6 +6,7 @@ ... +, incusGuestRole ? null
@@ -97,11 +99,10 @@ ... - incusImageAlias ... + message = "Incus canary config on pika-server requires endpoint, project, profile, storage pool, openclaw guest IPv4 CIDR, and openclaw proxy host together.";
@@ -269,7 +270,8 @@ ... - PIKA_AGENT_INCUS_IMAGE_ALIAS=${incusImageAlias} + ${lib.optionalString (incusGuestRole != null) "PIKA_AGENT_INCUS_GUEST_ROLE=${incusGuestRole}"} + ${lib.optionalString (incusImageAlias != null) "PIKA_AGENT_INCUS_IMAGE_ALIAS=${incusImageAlias}"}

The NixOS module pika-server.nix gains an optional incusGuestRole parameter. When set, it emits PIKA_AGENT_INCUS_GUEST_ROLE into the systemd environment. incusImageAlias becomes optional and is emitted only when explicitly provided — the server's Rust code now derives the alias from the guest role.

The assertion that previously required incusImageAlias is relaxed accordingly.

The production host config (pika-server.nix) switches from incusImageAlias = "pika-agent/dev" to incusGuestRole = "managed-openclaw", which resolves to the same alias via IncusGuestRole::default_image_alias().

Update downstream test fixtures

Intent: Keep tests in pika-git and other crates compiling after the struct changes.

Affected files: crates/pika-git/src/web/tests.rs, crates/pikaci/Cargo.toml, crates/pikaci/src/targets.rs

Evidence
@@ -136,6 +136,7 @@ fn write_pikaci_run_fixture ... + guest_role: None,
@@ -11,3 +11,4 @@ ... +pika-incus-guest-role = { workspace = true }
@@ -23,6 +24,7 @@ fn remote_incus_runtime ... + guest_role: IncusGuestRole::PikaciRunner,
  • pika-git web tests add guest_role: None to the RemoteLinuxVmImageRecord fixture.
  • pikaci adds the pika-incus-guest-role dependency and sets guest_role: IncusGuestRole::PikaciRunner on all CI target runtime configs, since CI jobs always run in the pikaci-runner role.

Diff