A hands-on guide to stand up the plan-server-update demo on your own checkout.
A deployed environment enrolls with a certificate authority, pulls a signed update plan from a
server, verifies every byte against a key it already trusts, and swaps v1 → v2 under a
snapshot it can roll back to. Every command here was run end-to-end and its real output captured — you'll see different random ids, but the same shape.
op updates verb yourself. Paths assume the Greentic repos sit side-by-side under
~/greentic — edit the first line of each block to match your machine. Ping me if a
step doesn’t reproduce.The updater is a set of operator verbs — today under op updates …, the same
tree that becomes gtc op updates … once it ships. It gives a running environment a
signed, pull-based path to a new version: enroll once, then fetch → verify → stage → apply,
with a snapshot and automatic rollback around the swap.
| Verb | What it does |
|---|---|
| enroll | Mint a key + CSR, exchange it at the Cert-CA for a signed client certificate, and persist cert/key/CA into the env secrets backend. Re-running re-enrolls (manual rotation). |
| status | Report the enrolled certificate's serial and validity window. |
| get | Fetch a signed update plan (over the enrolled mTLS channel or from a local file), verify it against the env trust root, and stage it. |
| apply | Apply a staged plan: re-verify, snapshot the whole environment, converge via the env-apply pipeline, and roll back on failure. |
| recover | Force a plan stranded in applying by a crashed applier to failed (audited, needs --force), so a fresh get + apply can proceed. |
| config-set | Set the notification policy: whether the runtime acts on a discovered update (record-only / stage) and the fallback poll interval. Deny-by-default. |
| config-show | Show the notification policy (stored fields + resolved effective values). Read-only. |
One environment starts on v1 (a WebChat bundle) and is updated to v2 (WebChat + Telegram) entirely through the signed channel:
operator key (seeded into the env trust root at `env init`)
| signs
v
[ server ] --- plan.json + plan.json.sig ---> [ environment ]
^ enroll (CSR -> cert) get: verify DSSE vs trust root -> stage
| apply: snapshot -> swap v1->v2 -> verify
[ Cert-CA ] v2 running (rollback point kept)The payoff you can see in the output: the key id that signs the plan,
the operator key id the env seeded, and the key id get verifies against are the
same value. Nothing outside the env's trust root can push it a new version.
The mechanism is the real, merged code. Two pieces of the "from a server" story are not built yet; each gets the smallest faithful substitute so the rest runs end-to-end today.
| Piece | Status | In the demo |
|---|---|---|
| Cert-CA / enrollment | real | greentic-updates-server (Axum) mints a real client cert from a real CA on enroll. |
| DSSE signing + trust-root verify | real | The exact greentic-update builder/verifier the runtime links. |
| get → stage → apply → rollback | real | The merged op updates verbs, run verbatim. |
| Plan issuance / serving | Phase 6 | No server mints plans yet, so plan-signer signs the v2 plan with the env's own operator key and a plain python3 -m http.server hands it out. |
| Auto-notification (webhook + poll) | Phase 4 | The receiver isn't built, so you run get/apply by hand; config-set records the policy it will honor. |
| Bundle bytes over the wire | local | The artifact fetcher accepts only https:///oci://, so the plan is zero-artifact and references the local v2.gtbundle — still verified against the signed bundle_digest. The signed plan still comes from the server. |
gtc-dev on PATH — used only to build the two sample .gtbundles.python3 (serves the signed plan over loopback) and curl.~/greentic — point it wherever yours live):
greentic-deployer on the branch that carries the op updates verbs,
my_demos/plan-server-update-demo (this demo, with plan-signer/), and
GREENTIC-BIZ/greentic-updates-server (the Cert-CA).op updates
verbs yet — checked 2026-07-06: stable gtc 1.0.22 and the latest dev build
gtc-dev v1.1.28792739341 both reject op updates (the verbs are merged on
develop but not yet compiled into a published gtc). The Cert-CA and the
plan-signer have no releases either. So you build a tiny examples/op.rs shim inside the
deployer checkout that exposes the same OpCommand tree; once the verbs land in a dev build,
drop the shim and call gtc-dev op updates … directly — same verbs, same arguments.One-time. All three cargo builds run under your real $HOME
(rustup/cargo need it); only the demo runtime later uses an isolated home.
op shim (the updater verbs)Drop the shim source (full listing below) into the deployer checkout's
examples/, then build it. The binary lands at
target/debug/examples/op.
cd ~/greentic/greentic-deployer
mkdir -p examples
# ... write examples/op.rs (see "The helper files" below) ...
cargo build --example opThe plan-signer/ crate already ships in this demo folder. Build it:
cd ~/greentic/my_demos/plan-server-update-demo/plan-signer
cargo buildcd ~/greentic/GREENTIC-BIZ/greentic-updates-server
cargo buildTwo distinct versions of one bundle id (updatedemo): v1 is WebChat only; v2 adds
the Telegram pack. Built from sample packs already in the workspace with gtc-dev.
DEMO=~/greentic/my_demos/plan-server-update-demo
PACKS=~/greentic/my_demos/webchat-gui-default/packs
mkdir -p $DEMO/build
# --- v1: webchat only ---
R1=$DEMO/build/bw-v1; rm -rf $R1; mkdir -p $R1/packs
gtc-dev dev bundle init $R1 --bundle-name updatedemo --bundle-id updatedemo --execute
gtc-dev dev bundle add app-pack $PACKS/app-webchat-bot.gtpack --root $R1 --execute
gtc-dev dev bundle build --root $R1 --output $DEMO/build/v1.gtbundle
# --- v2: webchat + telegram (the new version) ---
R2=$DEMO/build/bw-v2; rm -rf $R2; mkdir -p $R2/packs
gtc-dev dev bundle init $R2 --bundle-name updatedemo --bundle-id updatedemo --execute
gtc-dev dev bundle add app-pack $PACKS/app-webchat-bot.gtpack --root $R2 --execute
gtc-dev dev bundle add app-pack $PACKS/messaging-telegram.gtpack --root $R2 --execute
gtc-dev dev bundle build --root $R2 --output $DEMO/build/v2.gtbundle
# they must differ, or there is nothing to update to:
sha256sum $DEMO/build/v1.gtbundle $DEMO/build/v2.gtbundle0b1627480b96a8032f8c6c8309add51571e1a7075baf2d681ae338bb27e908ba v1.gtbundle
f80a6e5cc1da764abb4a36af06b92b815309661dc76deca1c1cbee09bca9f991 v2.gtbundleEverything the demo needs, as files — grab the zip, or copy each one from the listings
below. env.sh/env.fish set the paths and write the manifests; the signer's
full source is folded away at the bottom for reference.
env.sh — sets paths + digests, writes the manifestsSource it once after building the bundles. Edit the first line (WS) if your repos aren’t
under ~/greentic; it writes v1-manifest.json and v2-manifest.json
into the demo folder with the correct absolute bundle paths for your machine.
#!/usr/bin/env bash
# plan-server-update-demo — sets the demo paths, computes the bundle digests, and writes
# the two greentic.env-manifest.v1 files for THIS machine. Build the helpers + bundles
# first (see the guide), then: source env.sh
# Edit WS below if your Greentic repos aren't checked out under ~/greentic.
WS=~/greentic # your checkout root — everything below derives from it
DEMO=$WS/my_demos/plan-server-update-demo
DEMO_HOME=$DEMO/home # the isolated $HOME the environment lives under
BUILD=$DEMO/build
SERVED=$DEMO/served # plan.json + plan.json.sig the server hands out
OP=$WS/greentic-deployer/target/debug/examples/op
SIGNER=$DEMO/plan-signer/target/debug/plan-signer
CA=$WS/GREENTIC-BIZ/greentic-updates-server/target/debug/greentic-updates-server
mkdir -p $DEMO_HOME $SERVED
V1SHA=$(sha256sum $BUILD/v1.gtbundle | cut -d' ' -f1)
V2SHA=$(sha256sum $BUILD/v2.gtbundle | cut -d' ' -f1)
# target manifests: v1 = deploy FROM, v2 = update TO (absolute bundle_path + digest)
printf '{"schema":"greentic.env-manifest.v1","environment":{"id":"local"},"bundles":[{"bundle_id":"updatedemo","bundle_path":"%s","bundle_digest":"sha256:%s"}]}\n' \
"$BUILD/v1.gtbundle" "$V1SHA" > "$DEMO/v1-manifest.json"
printf '{"schema":"greentic.env-manifest.v1","environment":{"id":"local"},"bundles":[{"bundle_id":"updatedemo","bundle_path":"%s","bundle_digest":"sha256:%s"}]}\n' \
"$BUILD/v2.gtbundle" "$V2SHA" > "$DEMO/v2-manifest.json"
echo "env ready — OP=$OP"
echo "wrote $DEMO/v1-manifest.json (sha256:${V1SHA:0:16}...)"
echo "wrote $DEMO/v2-manifest.json (sha256:${V2SHA:0:16}...)"env.fish (same thing, fish syntax)#!/usr/bin/env fish
# plan-server-update-demo — fish version of env.sh. Build helpers + bundles first, then:
# source env.fish
# Edit WS below if your Greentic repos aren't checked out under ~/greentic.
set -gx WS ~/greentic
set -gx DEMO $WS/my_demos/plan-server-update-demo
set -gx DEMO_HOME $DEMO/home
set -gx BUILD $DEMO/build
set -gx SERVED $DEMO/served
set -gx OP $WS/greentic-deployer/target/debug/examples/op
set -gx SIGNER $DEMO/plan-signer/target/debug/plan-signer
set -gx CA $WS/GREENTIC-BIZ/greentic-updates-server/target/debug/greentic-updates-server
mkdir -p $DEMO_HOME $SERVED
set -gx V1SHA (sha256sum $BUILD/v1.gtbundle | cut -d' ' -f1)
set -gx V2SHA (sha256sum $BUILD/v2.gtbundle | cut -d' ' -f1)
printf '{"schema":"greentic.env-manifest.v1","environment":{"id":"local"},"bundles":[{"bundle_id":"updatedemo","bundle_path":"%s","bundle_digest":"sha256:%s"}]}\n' $BUILD/v1.gtbundle $V1SHA > $DEMO/v1-manifest.json
printf '{"schema":"greentic.env-manifest.v1","environment":{"id":"local"},"bundles":[{"bundle_id":"updatedemo","bundle_path":"%s","bundle_digest":"sha256:%s"}]}\n' $BUILD/v2.gtbundle $V2SHA > $DEMO/v2-manifest.json
echo "env ready — OP=$OP"v1-manifest.json / v2-manifest.json — the target statesv1 is the state you deploy from, v2 the state you update to. The bundle_digest
is fixed (same packs → same bytes); the bundle_path ships as a placeholder because it must
be absolute — source env.sh rewrites both for your machine, or edit it by hand. v2 is
identical but points at v2.gtbundle with digest sha256:f80a6e5c….
{
"schema": "greentic.env-manifest.v1",
"environment": { "id": "local" },
"bundles": [
{
"bundle_id": "updatedemo",
"bundle_path": "/ABSOLUTE/PATH/TO/my_demos/plan-server-update-demo/build/v1.gtbundle",
"bundle_digest": "sha256:0b1627480b96a8032f8c6c8309add51571e1a7075baf2d681ae338bb27e908ba"
}
]
}greentic-deployer/examples/op.rsThe whole op CLI: it parses OpCommand and dispatches. Compiling it inside the
deployer checkout gives you the full verb tree from that crate's own coherent dependency graph.
//! Demo-only `op` CLI shim (plan-server-update-demo): the full `op` verb tree
//! (env, trust-root, updates, ...) straight from this crate's coherent graph.
use clap::Parser;
use greentic_deployer::cli::dispatch::{OpCommand, dispatch_op};
fn main() {
if let Err(e) = dispatch_op(OpCommand::parse()) {
eprintln!("{e}");
std::process::exit(1);
}
}plan-signer/Cargo.tomlA standalone crate (its own [workspace]) so it never perturbs a real repo's lockfile. It
pins the same dev-lane greentic-update the runtime resolves, so the signed bytes are
byte-identical to what op updates get/apply verify.
# Demo-only helper: mints a signed `greentic.update-plan.v1` the way the (not-yet-built)
# Phase-6 plan-issuance server eventually will. Stands in for that server endpoint so the
# `op updates get` → verify → stage → apply chain can be demoed end-to-end today.
#
# Kept OUT of any Greentic workspace on purpose (standalone [workspace]) so it never
# perturbs a real repo's lockfile. It pins the SAME crates.io dev-lane `greentic-update`
# the runtime resolves, so the UpdatePlan struct + DSSE bytes are byte-identical to what
# `op updates get`/`apply` verify.
[package]
name = "plan-signer"
version = "0.0.0"
edition = "2021"
publish = false
[workspace]
[[bin]]
name = "plan-signer"
path = "src/main.rs"
[dependencies]
# Core update-plan builder + DSSE signing (no mtls/enroll features needed to sign).
greentic-update = "0.1"
# TrustRoot / TrustedKey / key_id_for_public_key_pem. Same dev-lane range greentic-update
# depends on, so cargo unifies to ONE distributor-client version (identical signing core).
greentic-distributor-client = ">=1.1.0-dev, <1.2.0-0"
ed25519-dalek = { version = "2", features = ["pkcs8", "pem"] }
chrono = "0.4"
clap = { version = "4", features = ["derive"] }
serde_json = "1"
anyhow = "1"plan-signer/src/main.rs (already in the repo; here for completeness)//! plan-signer — mint a signed `greentic.update-plan.v1` for the update-from-server demo.
//!
//! This is the Phase-6 plan-issuance server stand-in. A real Greentic update server will
//! eventually author + sign update plans server-side; that endpoint is not built yet, so
//! this helper signs one locally with the environment's own operator key (the key
//! `op env trust-root bootstrap` seeded into the env trust root). Because the plan is
//! signed by a key the env trusts, `op updates get` verifies it end-to-end exactly as it
//! would a server-issued plan.
//!
//! It calls `greentic_update::plan::build_update_plan` — the SAME builder the deployer
//! links — so the signed bytes are byte-identical to what `op updates get`/`apply` verify.
//! `build_update_plan` refuses to sign with an untrusted key and self-verifies the envelope
//! before returning, so a mis-keyed plan fails here rather than at the client.
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use clap::Parser;
use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
use ed25519_dalek::pkcs8::spki::EncodePublicKey;
use ed25519_dalek::pkcs8::DecodePrivateKey;
use ed25519_dalek::SigningKey;
use greentic_distributor_client::signing::{key_id_for_public_key_pem, TrustRoot, TrustedKey};
use greentic_update::plan::{
build_update_plan, CompatRequirements, OnFail, PlanArtifact, RollbackKind, RollbackPolicy,
UpdatePlan, UPDATE_PLAN_SCHEMA_V1,
};
use std::path::PathBuf;
#[derive(Parser)]
#[command(about = "Sign a greentic.update-plan.v1 with the env operator key (Phase-6 stand-in)")]
struct Args {
/// PKCS#8 Ed25519 operator private key PEM — the key `trust-root bootstrap` seeded.
#[arg(long)]
operator_key: PathBuf,
/// Target environment id. Must equal the plan header AND the manifest's environment.id.
#[arg(long)]
env_id: String,
/// Path to the target `greentic.env-manifest.v1` JSON (the desired next state).
#[arg(long)]
manifest: PathBuf,
/// Monotonic plan sequence. Must exceed the env's last-applied sequence (downgrade guard).
#[arg(long)]
sequence: u64,
/// Plan id.
#[arg(long, default_value = "demo-plan-v2")]
plan_id: String,
/// Single-use nonce (echoed by the notify layer for replay rejection).
#[arg(long, default_value = "demo-nonce-0001")]
nonce: String,
/// RFC-3339 creation timestamp (demo passes a fixed value for reproducibility).
#[arg(long)]
created_at: String,
/// Bundle artifact logical name (typically the bundle id). Omit for a zero-artifact
/// (config-only) plan whose target manifest references no downloadable content.
#[arg(long)]
artifact_name: Option<String>,
/// Bundle artifact version.
#[arg(long, default_value = "2.0.0")]
artifact_version: String,
/// Bundle artifact content digest, `sha256:<hex>`. MUST equal the manifest bundle_digest
/// and the actual bundle file's digest (env_apply re-verifies applied bytes against it).
/// Presence of this flag is what makes the plan carry an artifact.
#[arg(long)]
artifact_digest: Option<String>,
/// Where the DistClient fetches the bundle bytes: `file://…`, `oci://…`, or `https://…`.
#[arg(long)]
artifact_source: Option<String>,
/// Output directory; writes `plan.json` + `plan.json.sig`.
#[arg(long)]
out_dir: PathBuf,
}
fn main() -> Result<()> {
let args = Args::parse();
// Load the operator PKCS#8 private key; derive its SPKI public PEM + canonical key id
// via the SAME derivation the trust root uses, so the id matches what the env trusts.
let priv_pem = std::fs::read_to_string(&args.operator_key)
.with_context(|| format!("read operator key {}", args.operator_key.display()))?;
let signing_key =
SigningKey::from_pkcs8_pem(&priv_pem).context("parse operator key as PKCS#8 Ed25519")?;
let public_pem = signing_key
.verifying_key()
.to_public_key_pem(LineEnding::LF)
.context("encode operator SPKI public key")?;
let key_id = key_id_for_public_key_pem(&public_pem).context("derive operator key id")?;
// The env trusts exactly this operator key (bootstrap seeded it), so the build-side
// trust root is that one key. build_update_plan self-verifies against it before returning.
let trust = TrustRoot::new(vec![TrustedKey {
key_id: key_id.clone(),
public_key_pem: public_pem,
}]);
// The plan carries the target env-manifest.v1 as opaque JSON under the signature.
let target_bytes = std::fs::read(&args.manifest)
.with_context(|| format!("read target manifest {}", args.manifest.display()))?;
let target: serde_json::Value =
serde_json::from_slice(&target_bytes).context("parse target manifest JSON")?;
let created_at: DateTime<Utc> = DateTime::parse_from_rfc3339(&args.created_at)
.context("parse --created-at as RFC-3339")?
.with_timezone(&Utc);
// An artifact rides in the plan only when a digest is supplied. A config-only plan
// (no artifact) promotes straight to `staged`; a bundle plan fetches + stages the artifact.
let artifacts = match args.artifact_digest {
Some(digest) => vec![PlanArtifact {
name: args
.artifact_name
.context("--artifact-name is required when --artifact-digest is set")?,
version: args.artifact_version,
digest,
source: Some(
args.artifact_source
.context("--artifact-source is required when --artifact-digest is set")?,
),
}],
None => Vec::new(),
};
let plan = UpdatePlan {
schema: UPDATE_PLAN_SCHEMA_V1.to_string(),
plan_id: args.plan_id,
env_id: args.env_id,
sequence: args.sequence,
created_at,
nonce: args.nonce,
target,
artifacts,
compat: CompatRequirements::default(),
rollback: RollbackPolicy {
policy: RollbackKind::Auto,
health_timeout_s: 60,
on_fail: OnFail::Restore,
},
};
let built = build_update_plan(&plan, &priv_pem, &key_id, &trust)
.context("build + sign update plan (the operator key must be trusted by the env)")?;
std::fs::create_dir_all(&args.out_dir)
.with_context(|| format!("create out dir {}", args.out_dir.display()))?;
let plan_path = args.out_dir.join("plan.json");
let sig_path = args.out_dir.join("plan.json.sig");
std::fs::write(&plan_path, &built.plan_bytes)
.with_context(|| format!("write {}", plan_path.display()))?;
std::fs::write(&sig_path, &built.envelope_bytes)
.with_context(|| format!("write {}", sig_path.display()))?;
eprintln!("signed plan -> {}", plan_path.display());
eprintln!("dsse sidecar -> {}", sig_path.display());
eprintln!("plan_sha256 = {}", built.plan_sha256);
eprintln!("signer key_id = {}", built.key_id);
Ok(())
}Every op and plan-signer invocation runs against an isolated
home so it never touches your real ~/.greentic. Source env.sh from the download once (it sets the paths and writes both manifests), then paste each step.
# env.sh is in the download (full text below). It sets every path + digest and writes the
# two manifest files. Edit WS at its top if your repos aren't under ~/greentic, then:
source ~/greentic/my_demos/plan-server-update-demo/env.shenv.fish —
source …/env.fish instead. Both set the same variables and write the same
manifests; every command below then runs as env HOME=$DEMO_HOME $OP …, identical in bash
and fish.env init creates local and seeds the trust root with a
fresh operator key (this is the key that will sign update plans). env update sets the
tenant that enrollment binds the cert to. env apply deploys the v1 bundle from a
greentic.env-manifest.v1 that pins its bundle_digest.
env HOME=$DEMO_HOME $OP env init
env HOME=$DEMO_HOME $OP env update local --name local --tenant-org acme
# env.sh already wrote $DEMO/v1-manifest.json (bundle path + digest); deploy it:
env HOME=$DEMO_HOME $OP env apply --answers $DEMO/v1-manifest.json# env init
{"result":{"environment_id":"local","outcome":"created","pack_count":5,
"trust_root":{"operator_key_id":"0c320d556ded73b6cf90537414465b80","trusted_key_count":1}}}
# env apply
plan (2 step(s)):
ensure-environment local no-op exists (public_base_url unchanged)
deploy-bundle updatedemo create sha256:0b162748 -> default binding
{"result":{"changed":1,"verify":{"checked":1,"failures":[]}}}A genuine Axum CA that generates an in-memory CA on boot and signs CSRs at
/v1/enroll. Run it in the background on loopback; capture its PID so you can stop it later.
env HOME=$DEMO_HOME PORT=3140 $CA > $DEMO/.ca.log 2>&1 &
CA_PID=$!
# wait until it answers
until curl -sf http://127.0.0.1:3140/healthz >/dev/null; do sleep 0.2; done
echo "Cert-CA up (pid $CA_PID)"enroll mints a key + CSR, exchanges it at the CA for a real signed client
certificate, and stores the cert/key/CA into the env secrets backend. status reads the
stored identity back.
env HOME=$DEMO_HOME $OP updates enroll local --ca-url http://127.0.0.1:3140
env HOME=$DEMO_HOME $OP updates status local# enroll — cert stored under secrets://local/acme/_/tls/*
{"result":{"serial":"747e57d5cb1d5e8f9fcc36c874834fa6","tenant":"acme",
"not_after":"2027-07-06T17:35:10Z","secrets_kind":"greentic.secrets.dev-store@0.1.0",
"stored":[{"name":"updater_key"},{"name":"updater_ca"},{"name":"updater_ca_url"},{"name":"updater_cert"}]}}
# status
{"result":{"enrolled":true,"tenant":"acme","serial":"747e57d5cb1d5e8f9fcc36c874834fa6"}}plan-signer signs a greentic.update-plan.v1 for v2 using the
operator key env init just seeded — so the env will trust it. A zero-artifact plan
whose target manifest points at the local v2.gtbundle. Then serve the two files over
loopback HTTP.
# env.sh already wrote $DEMO/v2-manifest.json; sign it with the env's own operator key
env HOME=$DEMO_HOME $SIGNER \
--operator-key $DEMO_HOME/.greentic/operator/key.pem \
--env-id local --manifest $DEMO/v2-manifest.json \
--sequence 1 --plan-id updatedemo-v2 --created-at 2026-07-06T11:00:00Z \
--out-dir $SERVED
# serve plan.json + plan.json.sig
python3 -m http.server 3150 --directory $SERVED > $DEMO/.plansrv.log 2>&1 &
SRV_PID=$!
until curl -sf http://127.0.0.1:3150/plan.json >/dev/null; do sleep 0.2; done
echo "plan server up (pid $SRV_PID)"signed plan -> .../served/plan.json
dsse sidecar -> .../served/plan.json.sig
plan_sha256 = c84cd20f8f719e8bd88bb1d4f7a2ffbb198bf3b69f1cb8ab442a628cef8c05fa
signer key_id = 0c320d556ded73b6cf90537414465b80 <- same key the env trustsRecords what the runtime will do on a verified notification once the Phase-4
receiver lands: enabled, act by stage, fall back to polling every 900s.
Until then you drive get/apply by hand (next two steps).
env HOME=$DEMO_HOME $OP updates config-set local \
--enabled true --on-notify stage --poll-interval-secs 900
env HOME=$DEMO_HOME $OP updates config-show local{"result":{"enabled":true,"on_notify":"stage","poll_interval_secs":900,
"resolved":{"enabled":true,"on_notify":"stage","poll_interval_secs":900}}}get fetches the plan over the enrolled channel, verifies the DSSE
signature against the env trust root, and stages it. If the signature or key doesn't match a
trusted key, it refuses here — before anything touches staging.
env HOME=$DEMO_HOME $OP updates get local \
--plan-url http://127.0.0.1:3150/plan.json{"result":{"plan_id":"updatedemo-v2","sequence":1,"stage":"staged",
"verified_key_ids":["0c320d556ded73b6cf90537414465b80"],"artifacts_total":0,
"plan_sha256":"c84cd20f8f719e8bd88bb1d4f7a2ffbb198bf3b69f1cb8ab442a628cef8c05fa"}}apply re-verifies the staged plan, snapshots the whole environment,
converges to v2 through the env-apply pipeline (re-checking the applied bytes against the signed
bundle_digest), and keeps the snapshot as a rollback point. On any convergence or health
failure it rolls back automatically.
env HOME=$DEMO_HOME $OP updates apply local --plan-id updatedemo-v2plan (2 step(s)):
ensure-environment local no-op exists (public_base_url unchanged)
deploy-bundle updatedemo update digest sha256:0b162748 -> sha256:f80a6e5c (blue-green re-stage)
{"result":{"plan_id":"updatedemo-v2","stage":"applied",
"snapshot_id":"01KWW7Z34PF96TY9SGZREVJQ1K",
"apply_result":{"changed":1,"verify":{"checked":1,"failures":[]}}}}Re-running apply is refused — an applied plan is terminal. A plan with a lower
sequence than the last applied one is refused the same way (which also blocks replay).
env HOME=$DEMO_HOME $OP updates apply local --plan-id updatedemo-v2{"error":{"kind":"invalid-argument","message":"invalid argument: plan `updatedemo-v2` is `applied`,
not `staged`; only a staged plan can be applied"}}Three things across the output prove the whole chain held:
operator_key_id from step 1, the
signer key_id from step 4, and verified_key_ids[0] from step 6 are the
same value. The plan was signed by exactly the key the env trusts.sha256:0b162748 → sha256:f80a6e5c as a blue-green re-stage, with
verify.failures: [] — the applied bytes matched the signed digest.snapshot_id was kept, and a second
apply (or any lower-sequence plan) is refused.Stop the two background servers (the PIDs you captured in steps 2 and 4) and wipe the isolated home.
kill $CA_PID $SRV_PID 2>/dev/null
rm -rf $DEMO_HOME $SERVED $DEMO/v1-manifest.json $DEMO/v2-manifest.json$DEMO/home/.greentic — your
real ~/.greentic was never touched. The two servers only ever bound 127.0.0.1.