Start an environment. Start the plan server. Publish an update on the server. The running client discovers it, verifies it against a key it already trusts, and stages it — nobody pushed anything at it. One command then swaps traffic onto the new version, under a snapshot it can roll back to.
~/.greentic.A way to move a deployed environment from one version to the next without anyone logging into it.
The operator signs a description of the desired next state of an environment — a
greentic.env-manifest.v1 — and publishes it. Environments that have been pointed at
that publisher poll it, fetch the plan, check the signature against their own trust root, and
stage it. Applying the staged plan is a deliberate, separate act.
Three properties fall out of that shape, and this demo shows all three:
| Property | How it is enforced |
|---|---|
| The server cannot forge an update | The plan is DSSE-signed by the operator key. The server stores bytes and answers GETs; it holds no signing key. A tampered plan fails verification at the client. |
| The client cannot be pushed a downgrade | Each plan carries a monotonic sequence. The signed plan — not the server's
advisory metadata — is what the anti-rollback check reads. |
| Discovery is not authority | Discovering a plan stages it. Nothing changes what is serving traffic until
op updates apply runs, under a snapshot with automatic rollback. |
One key, minted locally, seeded into the environment, never sent anywhere.
op env init mints an operator key at ~/.greentic/operator/key.pem
and seeds its public half into the environment's trust root. That is the whole ceremony.
op updates plan-build signs with that private key. The runtime's poll loop verifies
against that public key before it will stage anything. So the same key_id appears
three times in the run below — in env init, in plan-build, and implicitly
in every successful verification. If they ever diverge, staging fails closed.
Two released binaries, one you build, and the two sample packs already in the workspace.
greentic-deployer ≥ 1.1.9 — the op verb tree.
cargo binstall greentic-deployer@1.1.9greentic-start ≥ 1.1.8 — the runtime whose poll loop does the pulling.
cargo binstall greentic-start@1.1.8greentic-updates-server — the plan server. A private greentic-biz repo with
no crates.io publish and no GitHub release, so this is the one thing you build. Make sure the
checkout is on main — the plan store landed there, and an older branch answers
404 to the upload:
cd GREENTIC-BIZ/greentic-updates-server && git switch main && cargo buildgtc — only to build the two demo bundles from the sample packs.curl, python3, sha256sum.op updates plan-build first shipped in
1.1.8, but it hard-required a --binary artifact — it could only mint plans for the
binary self-update path. 1.1.9 lets it sign a content-only plan from a
--target-file, which is what an ordinary environment update is. On 1.1.8 you get
invalid argument: at least one --binary is required. 1.1.9 is on
crates.io and has release binaries for Linux (x64, arm64), Apple Silicon and Windows (x64, arm64),
so binstall resolves it without a source build.The script below is the seven steps, verbatim, with the waits and the cleanup wired up.
cd my_demos/plan-server-update-demo
./demo.sh══ check the released binaries ══
✓ greentic-deployer 1.1.9
✓ greentic-start 1.1.8
✓ greentic-updates-server …/target/debug/greentic-updates-server
══ 1. start the environment (running v1) ══
▸ op env init — creates the env, mints the operator key, seeds the trust root
outcome=created operator_key_id=6ce4f50a04f814236b16ebbf9d0faae3
▸ op env apply — deploy v1
✓ v1 is live:
sequence 1 sha256:7c70a41c63058e8b8c60ccb4fbf09ead6344f5a9b3fdcc5193a64f90144d7a6a [app-webchat-bot]
══ 2. start the plan server ══
✓ listening on http://127.0.0.1:3140
══ 3. sign the update ══
▸ op updates plan-build — DSSE-sign the v2 env-manifest with the operator key
plan_id=01KX041WERDPHJA2PQT1N14JQQ sequence=2 key_id=6ce4f50a04f814236b16ebbf9d0faae3
✓ the signing key never leaves this machine
══ 4. publish the update on the server ══
http 201
{"sequence":2,"status":"stored"}
══ 5. point the environment at the plan server ══
enabled=True on_notify=stage poll_interval_secs=60
plan_endpoint=http://127.0.0.1:3140/v1/environments/local/plan
══ 6. the client updates itself ══
▸ waiting for the runtime to fetch → DSSE-verify → stage…
poll: env `local` plan sequence 2 -> 200 (staged)
✓ the RUNTIME pulled the plan, verified it against the trust root, and staged it
nobody pushed it — the server only answered a GET
══ 7. swap traffic onto v2 ══
ensure-environment no-op exists (public_base_url unchanged)
deploy-bundle update digest sha256:7c70a41c → sha256:e2d75f53 (blue-green re-stage)
stage=applied verify.failures=[]
✓ v2 is live:
sequence 2 sha256:e2d75f538dbd0e01761e616f780f4daa0a43f67b90e3b2b28fbe0e8cc5ad9d17 [app-webchat-bot, messaging-telegram]
▸ the plan is terminal — re-applying it is refused, not repeated
invalid argument: plan `01KX041WERDPHJA2PQT1N14JQQ` is `applied`, not `staged`; only a staged plan can be applied
✓ sequence 1 → 2, digest changed, messaging-telegram joined webchat.Note the
operator_key_id from step 1 and the key_id from step 3: the same key.
The runtime verified against it in step 6 without ever being handed it.
#!/usr/bin/env bash
# ===========================================================================
# plan-server-update-demo
#
# Start an environment → start the plan server → publish an update on the
# server → the running client picks it up, verifies it, and stages it by
# itself → one command swaps traffic onto the new version.
#
# Everything runs on RELEASED binaries. No signer helper, no CLI shim, no
# enrollment step:
#
# greentic-deployer >= 1.1.9 cargo binstall greentic-deployer@1.1.9
# greentic-start >= 1.1.8 cargo binstall greentic-start@1.1.8
# greentic-updates-server built from source (private repo, no release)
#
# The trust chain is real end to end: `op env init` mints the operator key and
# seeds it into the environment's trust root; `op updates plan-build` DSSE-signs
# the plan with that key; the runtime's poll loop verifies the signature against
# the trust root before it will stage anything. The plan server never holds a
# signing key — it only stores and serves what the operator signed.
#
# Usage:
# ./demo.sh build the bundles (if missing), then run
# ./demo.sh build build the two bundles only
# ./demo.sh clean remove demo-local state (home/, served/, build/)
#
# For the Phase-7d binary self-update (the runtime swapping its own binary),
# see ./demo-binary-selfupdate.sh — that one needs source builds.
# ===========================================================================
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WS="$(cd "$HERE/../.." && pwd)" # greenticai workspace root
# --- binaries -------------------------------------------------------------
DEP="${DEP:-greentic-deployer}" # >= 1.1.9 (the `op` verbs)
RUNTIME="${RUNTIME:-greentic-start}" # >= 1.1.8 (the poll loop)
# The update server is a private greentic-biz repo with no crates.io publish and
# no GitHub release, so it is the one thing you build yourself.
SRV_DIR="${SRV_DIR:-$WS/GREENTIC-BIZ/greentic-updates-server}"
SRV_BIN="${SRV_BIN:-$SRV_DIR/target/debug/greentic-updates-server}"
# Bundle authoring CLI, only used by `demo.sh build`.
GTC="${GTC:-gtc}"
# --- content: two bundles built from existing sample packs ----------------
APP_PACK="$WS/my_demos/webchat-gui-default/packs/app-webchat-bot.gtpack"
TG_PACK="$WS/my_demos/webchat-gui-default/packs/messaging-telegram.gtpack"
# --- demo-local, isolated state (never touches your real ~/.greentic) -----
HOME_DIR="$HERE/home" # becomes $HOME for every command below
BUILD_DIR="$HERE/build" # v1/v2 bundles land here
SERVED_DIR="$HERE/served" # plan.json + plan.json.sig
ENV_ID="local"
BUNDLE_ID="updatedemo"
SRV_PORT="${SRV_PORT:-3140}"
UPLOAD_TOKEN="${UPLOAD_TOKEN:-demo-upload-token}" # X-Api-Key for POST …/plan
SRV_URL="http://127.0.0.1:$SRV_PORT"
PLAN_ENDPOINT="$SRV_URL/v1/environments/$ENV_ID/plan" # the poll loop appends /meta and .sig
# --- presentation ---------------------------------------------------------
if [ -t 1 ]; then BOLD=$'\e[1m'; GRN=$'\e[32m'; CYN=$'\e[36m'; RED=$'\e[31m'; DIM=$'\e[2m'; Z=$'\e[0m'
else BOLD= GRN= CYN= RED= DIM= Z=; fi
say() { printf '%s\n' "${CYN}${BOLD}▸ $*${Z}"; }
ok() { printf '%s\n' "${GRN} ✓ $*${Z}"; }
info() { printf '%s\n' " $*"; }
dim() { printf '%s\n' "${DIM} $*${Z}"; }
die() { printf '%s\n' "${RED}✗ $*${Z}" >&2; exit 1; }
step() { printf '\n%s\n' "${BOLD}══ $* ══${Z}"; }
# --- background processes: PID-based cleanup, never `pkill -f` ------------
PIDS=()
cleanup() { local p; for p in "${PIDS[@]:-}"; do kill "$p" 2>/dev/null || true; done; }
start_bg() { local log="$1"; shift; "$@" >"$log" 2>&1 & PIDS+=($!); }
# Every command runs against the isolated home. `op` is a subcommand of the
# deployer binary: `greentic-deployer op <noun> <verb>`.
op() { env HOME="$HOME_DIR" "$DEP" op "$@"; }
# Print the revision currently serving 100% of traffic.
show_live() {
op env show "$ENV_ID" | python3 -c '
import json, sys
env = json.load(sys.stdin)["result"]["environment"]
live = {e["revision_id"] for s in env["traffic_splits"] for e in s["entries"] if e["weight_bps"] > 0}
for r in env["revisions"]:
if r["revision_id"] in live:
packs = ", ".join(p["pack_id"] for p in r["pack_list"])
print(" sequence %s %s [%s]" % (r["sequence"], r["bundle_digest"], packs))
'
}
need() { command -v "$1" >/dev/null 2>&1 || die "$1 not on PATH${2:+ — $2}"; }
version_at_least() { # version_at_least <binary> <min>
local have; have=$("$1" --version 2>/dev/null | awk '{print $NF}')
python3 - "$have" "$2" <<'PY' || die "$1 is $have, need >= $2"
import sys
def parts(v): return [int(x) for x in v.split("-")[0].split(".")]
sys.exit(0 if parts(sys.argv[1]) >= parts(sys.argv[2]) else 1)
PY
printf '%s\n' "${GRN} ✓ $1 $have${Z}"
}
# ===========================================================================
cmd_clean() {
rm -rf "$HOME_DIR" "$SERVED_DIR" "$BUILD_DIR"
rm -f "$HERE"/.server.log "$HERE"/.runtime.log
ok "removed home/, served/, build/ and logs"
}
# ===========================================================================
cmd_build() {
step "build the content: two versions of one bundle"
[ -f "$APP_PACK" ] || die "missing sample pack $APP_PACK"
[ -f "$TG_PACK" ] || die "missing sample pack $TG_PACK"
need "$GTC" "set GTC=<path> to a gtc binary that can build bundles"
mkdir -p "$BUILD_DIR"
say "v1 — webchat only (the version we update FROM)"
local r1="$BUILD_DIR/ws-v1"; rm -rf "$r1"; mkdir -p "$r1/packs"
"$GTC" dev bundle init "$r1" --bundle-name "$BUNDLE_ID" --bundle-id "$BUNDLE_ID" --execute >/dev/null
cp "$APP_PACK" "$r1/packs/"
"$GTC" dev bundle add app-pack "$APP_PACK" --root "$r1" --execute >/dev/null
"$GTC" dev bundle build --root "$r1" --output "$BUILD_DIR/v1.gtbundle" >/dev/null
ok "v1.gtbundle sha256:$(sha256sum "$BUILD_DIR/v1.gtbundle" | cut -c1-16)…"
say "v2 — webchat + telegram (the version we update TO)"
local r2="$BUILD_DIR/ws-v2"; rm -rf "$r2"; mkdir -p "$r2/packs"
"$GTC" dev bundle init "$r2" --bundle-name "$BUNDLE_ID" --bundle-id "$BUNDLE_ID" --execute >/dev/null
cp "$APP_PACK" "$TG_PACK" "$r2/packs/"
"$GTC" dev bundle add app-pack "$APP_PACK" --root "$r2" --execute >/dev/null
"$GTC" dev bundle add app-pack "$TG_PACK" --root "$r2" --execute >/dev/null
"$GTC" dev bundle build --root "$r2" --output "$BUILD_DIR/v2.gtbundle" >/dev/null
ok "v2.gtbundle sha256:$(sha256sum "$BUILD_DIR/v2.gtbundle" | cut -c1-16)…"
[ "$(sha256sum "$BUILD_DIR/v1.gtbundle" | cut -d' ' -f1)" \
!= "$(sha256sum "$BUILD_DIR/v2.gtbundle" | cut -d' ' -f1)" ] \
|| die "v1 and v2 have the same digest — nothing to update to"
}
# Write the two env-manifests for THIS machine. `bundle_path` must be absolute:
# a relative path resolves against $HOME, not the working directory.
write_manifests() {
local v; for v in v1 v2; do
python3 - "$BUILD_DIR/$v.gtbundle" "$ENV_ID" "$BUNDLE_ID" "$HERE/$v-manifest.json" <<'PY'
import hashlib, json, sys
path, env_id, bundle_id, out = sys.argv[1:5]
digest = hashlib.sha256(open(path, "rb").read()).hexdigest()
json.dump({"schema": "greentic.env-manifest.v1",
"environment": {"id": env_id},
"bundles": [{"bundle_id": bundle_id,
"bundle_path": path,
"bundle_digest": "sha256:" + digest}]},
open(out, "w"))
PY
done
}
# ===========================================================================
cmd_run() {
{ [ -f "$BUILD_DIR/v1.gtbundle" ] && [ -f "$BUILD_DIR/v2.gtbundle" ]; } || cmd_build
step "check the released binaries"
need python3; need curl; need sha256sum
need "$DEP" "cargo binstall greentic-deployer@1.1.9"
need "$RUNTIME" "cargo binstall greentic-start@1.1.8"
version_at_least "$DEP" 1.1.9
version_at_least "$RUNTIME" 1.1.8
[ -x "$SRV_BIN" ] || die "update server not built: $SRV_BIN
cd $SRV_DIR && cargo build"
ok "greentic-updates-server $SRV_BIN"
rm -rf "$HOME_DIR" "$SERVED_DIR"
mkdir -p "$HOME_DIR" "$SERVED_DIR"
write_manifests
trap cleanup EXIT
# -------------------------------------------------------------------------
step "1. start the environment (running v1)"
say "op env init — creates the env, mints the operator key, seeds the trust root"
op env init | python3 -c '
import json, sys
r = json.load(sys.stdin)["result"]
print(" outcome=%s operator_key_id=%s" % (r["outcome"], r["trust_root"]["operator_key_id"]))'
say "op env apply — deploy v1"
# The verb streams its convergence plan to stderr; the JSON outcome to stdout.
op env apply --answers "$HERE/v1-manifest.json" >/dev/null 2>&1
ok "v1 is live:"
show_live
# -------------------------------------------------------------------------
step "2. start the plan server"
say "greentic-updates-server — stores and serves signed plans; never signs"
start_bg "$HERE/.server.log" env PORT="$SRV_PORT" PLAN_UPLOAD_TOKEN="$UPLOAD_TOKEN" "$SRV_BIN"
local i
for i in $(seq 1 40); do curl -sf "$SRV_URL/healthz" >/dev/null 2>&1 && break; sleep 0.25; done
curl -sf "$SRV_URL/healthz" >/dev/null || { tail -5 "$HERE/.server.log" >&2; die "server did not come up"; }
ok "listening on $SRV_URL"
# -------------------------------------------------------------------------
step "3. sign the update"
say "op updates plan-build — DSSE-sign the v2 env-manifest with the operator key"
op updates plan-build "$ENV_ID" --sequence 2 --target-file "$HERE/v2-manifest.json" --out-dir "$SERVED_DIR" \
| python3 -c '
import json, sys
r = json.load(sys.stdin)["result"]
print(" plan_id=%s sequence=%s key_id=%s" % (r["plan_id"], r["sequence"], r["key_id"]))'
local PLAN_ID
PLAN_ID=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["plan_id"])' "$SERVED_DIR/plan.json")
dim "wrote $SERVED_DIR/plan.json + plan.json.sig"
ok "the signing key never leaves this machine"
# -------------------------------------------------------------------------
step "4. publish the update on the server"
say "POST /v1/environments — register the env (plan endpoints 404 until it exists)"
curl -sS -X POST "$SRV_URL/v1/environments" -H 'Content-Type: application/json' \
-d "{\"id\":\"$ENV_ID\",\"tenant\":\"acme\",\"env\":\"$ENV_ID\"}" -o /dev/null -w ' http %{http_code}\n'
say "POST …/plan — upload the signed plan (X-Api-Key; sequence is strictly monotonic)"
python3 - "$SERVED_DIR/plan.json" "$SERVED_DIR/plan.json.sig" "$SERVED_DIR/upload.json" <<'PY'
import base64, hashlib, json, sys
plan = open(sys.argv[1], "rb").read()
env = open(sys.argv[2], "rb").read()
json.dump({"plan_bytes_b64": base64.b64encode(plan).decode(),
"envelope_bytes_b64": base64.b64encode(env).decode(),
"sequence": 2,
"plan_sha256": hashlib.sha256(plan).hexdigest()}, open(sys.argv[3], "w"))
PY
local upload
upload=$(curl -sS -X POST "$PLAN_ENDPOINT" -H 'Content-Type: application/json' -H "X-Api-Key: $UPLOAD_TOKEN" \
--data-binary "@$SERVED_DIR/upload.json")
printf ' %s\n' "$upload"
# A server built from a branch without the plan store answers 404 here. Fail
# now, with the cause, rather than at the poll loop three steps later.
python3 -c 'import json,sys;sys.exit(0 if json.loads(sys.argv[1]).get("status")=="stored" else 1)' "$upload" 2>/dev/null \
|| die "the plan upload was refused — is $SRV_DIR checked out on \`main\` (the branch with the plan store) and rebuilt?"
# -------------------------------------------------------------------------
step "5. point the environment at the plan server"
say "op updates config-set — the channel is deny-by-default until this runs"
op updates config-set "$ENV_ID" --enabled true --on-notify stage \
--poll-interval-secs 60 --plan-endpoint "$PLAN_ENDPOINT" \
| python3 -c '
import json, sys
r = json.load(sys.stdin)["result"]
print(" enabled=%s on_notify=%s poll_interval_secs=%s" % (r["enabled"], r["on_notify"], r["poll_interval_secs"]))
print(" plan_endpoint=%s" % r["plan_endpoint"])'
# -------------------------------------------------------------------------
step "6. the client updates itself"
say "greentic-start start — serves v1; its poll loop discovers the plan on its own"
start_bg "$HERE/.runtime.log" env HOME="$HOME_DIR" "$RUNTIME" start --env "$ENV_ID" --no-browser
say "waiting for the runtime to fetch → DSSE-verify → stage…"
local staged="" st
for i in $(seq 1 120); do
st="$HOME_DIR/.greentic/updates/$ENV_ID/$PLAN_ID/state.json"
if [ -f "$st" ] && python3 -c 'import json,sys;sys.exit(0 if json.load(open(sys.argv[1]))["stage"]=="staged" else 1)' "$st" 2>/dev/null
then staged=1; break; fi
sleep 1
done
[ -n "$staged" ] || { tail -25 "$HERE/.runtime.log" >&2; die "the poll loop did not stage the plan (see .runtime.log)"; }
grep -m1 "update-poll: env" "$HERE/.runtime.log" | sed 's/^.*update-poll:/ poll:/'
ok "the RUNTIME pulled the plan, verified it against the trust root, and staged it"
dim "nobody pushed it — the server only answered a GET"
# -------------------------------------------------------------------------
step "7. swap traffic onto v2"
say "op updates apply — snapshot, converge, verify; roll back on failure"
op updates apply "$ENV_ID" --plan-id "$PLAN_ID" 2>/dev/null | python3 -c '
import json, sys
r = json.load(sys.stdin)["result"]
for s in r["apply_result"]["steps"]:
print(" %-20s %-8s %s" % (s["kind"], s["action"], s["detail"]))
print(" stage=%s verify.failures=%s" % (r["stage"], r["apply_result"]["verify"]["failures"]))'
ok "v2 is live:"
show_live
say "the plan is terminal — re-applying it is refused, not repeated"
# A failing verb writes its error envelope to stderr and exits non-zero, so
# capture it rather than piping (`set -o pipefail` would abort the script).
local refusal
refusal=$(op updates apply "$ENV_ID" --plan-id "$PLAN_ID" 2>&1 >/dev/null || true)
printf '%s' "$refusal" | python3 -c 'import json,sys;print(" " + json.load(sys.stdin)["error"]["message"])'
printf '\n%s\n' "${GRN}${BOLD}✓ sequence 1 → 2, digest changed, messaging-telegram joined webchat.${Z}"
info "state lives under $HOME_DIR — nothing touched your real ~/.greentic"
}
case "${1:-run}" in
run) cmd_run ;;
build) cmd_build ;;
clean) cmd_clean ;;
*) die "usage: $0 [run|build|clean]" ;;
esac
Every command runs against an isolated $HOME, so nothing here can disturb a
real environment. op is a subcommand of the deployer binary:
greentic-deployer op <noun> <verb>.
DEMO=~/greentic/my_demos/plan-server-update-demo
DEMO_HOME=$DEMO/home # the isolated $HOME the environment lives under
SERVED=$DEMO/served # plan.json + plan.json.sig
SRV=http://127.0.0.1:3140 # the plan server
PLAN_ENDPOINT=$SRV/v1/environments/local/plan # the poll loop appends /meta and .sig
TOKEN=demo-upload-token # X-Api-Key for uploads
OP=greentic-deployer # commands below read `$OP op …`
mkdir -p $DEMO_HOME $SERVEDenv init creates the environment, mints the operator key, and seeds
the trust root — one command, no separate bootstrap. env apply then converges it
onto the v1 manifest.
env HOME=$DEMO_HOME $OP op env init
env HOME=$DEMO_HOME $OP op env apply --answers $DEMO/v1-manifest.json{"noun":"env","op":"init","result":{
"outcome":"created",
"trust_root":{"environment_id":"local","trusted_key_count":1,
"operator_key_id":"6ce4f50a04f814236b16ebbf9d0faae3"}}}Read the environment back and pick the revision serving 100% of traffic.
env HOME=$DEMO_HOME $OP op env show local | jq '.result.environment
| (.traffic_splits[0].entries[] | select(.weight_bps > 0) | .revision_id) as $live
| .revisions[] | select(.revision_id == $live)
| {sequence, bundle_digest, packs: [.pack_list[].pack_id]}'{
"sequence": 1,
"bundle_digest": "sha256:7c70a41c63058e8b8c60ccb4fbf09ead6344f5a9b3fdcc5193a64f90144d7a6a",
"packs": [
"app-webchat-bot"
]
}It is a Cert-CA, an environment registry, and a plan store in one binary. Here only
the plan store matters. PLAN_UPLOAD_TOKEN is deny-by-default: leave it unset and
uploads are refused outright.
PORT=3140 PLAN_UPLOAD_TOKEN=$TOKEN \
~/greentic/GREENTIC-BIZ/greentic-updates-server/target/debug/greentic-updates-server &
curl -sf $SRV/healthz && echo " server up"The v2 manifest goes under a DSSE signature made with the operator key from
step 1. plan-build refuses to sign with a key the environment's trust root does
not already contain, and it self-verifies the envelope before writing it out.
env HOME=$DEMO_HOME $OP op updates plan-build local \
--sequence 2 \
--target-file $DEMO/v2-manifest.json \
--out-dir $SERVED{"noun":"updates","op":"plan-build","result":{
"environment_id":"local",
"plan_id":"01KX041WERDPHJA2PQT1N14JQQ",
"sequence":2,
"key_id":"6ce4f50a04f814236b16ebbf9d0faae3",
"plan_sha256":"15f0dd78c41b7c81…",
"plan_path":"…/served/plan.json",
"sig_path":"…/served/plan.json.sig"}}The private key never leaves the machine, and it is never given to the server.
Register the environment first — the plan endpoints 404 until it exists. The upload
is credentialed with X-Api-Key; reads are anonymous by design, because the plan's
signature is what protects it. The server checks plan_sha256 and rejects any
sequence that is not strictly greater than the last one it stored.
curl -sS -X POST $SRV/v1/environments -H 'Content-Type: application/json' \
-d '{"id":"local","tenant":"acme","env":"local"}'
python3 - $SERVED/plan.json $SERVED/plan.json.sig $SERVED/upload.json <<'PY'
import base64, hashlib, json, sys
plan = open(sys.argv[1], "rb").read(); env = open(sys.argv[2], "rb").read()
json.dump({"plan_bytes_b64": base64.b64encode(plan).decode(),
"envelope_bytes_b64": base64.b64encode(env).decode(),
"sequence": 2,
"plan_sha256": hashlib.sha256(plan).hexdigest()}, open(sys.argv[3], "w"))
PY
curl -sS -X POST $PLAN_ENDPOINT -H 'Content-Type: application/json' \
-H "X-Api-Key: $TOKEN" --data-binary @$SERVED/upload.json{"id":"local", …} # 201 Created
{"sequence":2,"status":"stored"}The update channel is deny-by-default. Until this runs, the runtime has no endpoint
to poll and would ignore an update even if one existed. on-notify=stage means
"verify and stage a discovered plan, change nothing".
env HOME=$DEMO_HOME $OP op updates config-set local \
--enabled true \
--on-notify stage \
--poll-interval-secs 60 \
--plan-endpoint $PLAN_ENDPOINT{"enabled":true,"on_notify":"stage","poll_interval_secs":60,
"plan_endpoint":"http://127.0.0.1:3140/v1/environments/local/plan"}Nothing else happens here. greentic-start serves v1 and spawns a poll
loop that fires immediately. It GETs …/plan/meta for the sequence, then
…/plan and …/plan.sig, checks the plan digest against the metadata to
drop torn reads, verifies the DSSE envelope against the trust root, and stages it.
env HOME=$DEMO_HOME greentic-start start --env local --no-browserupdate-poll: env `local` plan sequence 2 -> 200 (staged)That line reads oddly on purpose: sequence 2 is the plan the server
offered, 200 is the receiver's status code, staged is the outcome.
Nobody pushed anything — the server only answered a GET.
cat $DEMO_HOME/.greentic/updates/local/<plan_id>/state.json{"stage":"staged", …}Traffic is still on v1. Discovery is not authority.
The one deliberate act. apply re-verifies the plan, snapshots the
environment, converges it through the same env apply pipeline, verifies the result,
and restores the snapshot if anything fails.
env HOME=$DEMO_HOME $OP op updates apply local --plan-id 01KX041WERDPHJA2PQT1N14JQQplan (2 step(s)):
ensure-environment local no-op exists (public_base_url unchanged)
deploy-bundle updatedemo update digest sha256:7c70a41c → sha256:e2d75f53 (blue-green re-stage)
{"stage":"applied","sequence":2,"snapshot_id":"01KX02SHFRF9GWB6M9GY6DQJY7",
"apply_result":{"changed":1,"no_op":1,"verify":{"checked":1,"failures":[]}}}Run the exact same command as in step 1:
env HOME=$DEMO_HOME $OP op env show local | jq '…same filter…'{
"sequence": 2,
"bundle_digest": "sha256:e2d75f538dbd0e01761e616f780f4daa0a43f67b90e3b2b28fbe0e8cc5ad9d17",
"packs": [
"app-webchat-bot",
"messaging-telegram"
]
}Three things flipped, all proving the update landed: sequence 1 → 2,
the digest changed to the one named in the blue-green re-stage line above, and
messaging-telegram appeared alongside webchat.
Four signals. If any is missing, the run did not prove what it claims.
| Signal | Where | Why it matters |
|---|---|---|
| same key_id | env init and plan-build |
The plan is signed by the key the environment already trusts. A different key would fail
closed at plan-build before anything was written. |
| -> 200 (staged) | runtime log | The runtime discovered, fetched and verified the plan on its own. No push, no operator shell on the box. |
| failures: [] | updates apply |
Post-apply verification re-read the environment and found it matches the signed target. A non-empty list would have triggered a snapshot restore. |
| sequence 1 → 2 | env show |
The live revision actually moved, and the monotonic sequence advanced — so the same plan cannot be replayed. |
op updates apply with the same
--plan-id. It refuses: plan … is `applied`, not `staged`.
Applied plans are terminal.One command. The two background processes stop with the script.
./demo.sh clean$DEMO/home/.greentic — your
real ~/.greentic was never touched. The server only ever bound
127.0.0.1.Looking for the runtime swapping its
own binary from a digest pinned inside the signed plan? That is
demo-binary-selfupdate.sh in the same directory. It needs source builds — the binary
self-update landed on greentic-start main after the 1.1.8 release cut.