Skip to content

Script the CVM deploy, and write down what a cluster move costs - #101

Draft
HONGJICAI wants to merge 5 commits into
mainfrom
claude/phala-gateway-migration-gmm3h6
Draft

Script the CVM deploy, and write down what a cluster move costs#101
HONGJICAI wants to merge 5 commits into
mainfrom
claude/phala-gateway-migration-gmm3h6

Conversation

@HONGJICAI

Copy link
Copy Markdown
Collaborator

Two thirds of a release were already scripted and one third was not. release.yml produces the attested artifact, switch.sh moves traffic between two CVMs — and in between sat a dashboard form. That form is not a convenience gap: allowed_envs, the CVM name, and the public_logs / public_sysinfo / public_tcbinfo / secure_time flags all land in the measured app-compose, so every field in it is an unrecorded input to app_id, and two blue/green sides can only be compared if they were filled in identically.

deploy/phala/deploy.sh is that record, plus a README section on what moving GATEWAY_DOMAIN actually costs.

deploy.sh

cp deploy.env.example deploy.env               # once
./deploy.sh preflight --side b                 # checks only, changes nothing
./deploy.sh deploy --side b --release latest
  • Takes a release's digest-pinned docker-compose.release.yml, checked against the sha256 the release publishes (the API's asset digest, falling back to the notes), and refuses any gateway-image line still on a tag — including cvm-identity, which runs as root with the guest-agent socket mounted, and which must carry the same digest as gateway.
  • Hands the CLI a mode-600 env file, not -e KEY=VALUE: an argv is world-readable in ps for the life of the call and four of these values are secrets.
  • Reads back the two DNS records whose absence fails silently — the serving alias must name the cluster being deployed to, and the issuance switch must already point at this side (otherwise the side's first ACME run burns the 5-failed-validations-per-hour budget for the hostname, blocking the live side's renewal too).
  • Waits on /readyz by app-id (<app_id>-443s.<base>), so the new CVM is verified before anything points at it. A cold side has to finish its first warmer sweep, so that window is minutes.
  • Defaults: public_logs and public_sysinfo off (this CVM handles sealed prompts, and anything a container logs is disclosed), public_tcbinfo on (pcverify fetches app-compose.json through it), never --dev-os (it wants an SSH key, i.e. interactive root inside the enclave).
  • Never moves traffic and never edits DNS — it only reads it. Cutting over stays switch.sh's separate decision.

It also corrects the CLI: phala cvm create is gone in favour of phala deploy, and --vcpu/--memory in favour of --instance-type. INSTANCE_TYPE is deliberately left with no default, because GOMEMLIMIT=24GiB in the compose was derived by hand from a 32 GiB CVM and nothing in the manifest can check the shape.

Two facts about the CVM name, which are independent

It is measured. dstack's AppCompose carries a name field and app_id is that manifest's hash, so the name is inside app_id — the live deployment's own pcverify output prints app name 0g-pc-gateway-a-1. It is not a label to be tidied later: a new name is a new app_id, i.e. a new CVM and a re-audit. So the default is 0g-pc-gateway-<ZG_PROM_ENV>-<side>; without the env, a staging CVM and a mainnet one on the same build would hash to one app_id, since DOMAIN and the router URL are ${…} references whose values are never measured.

This corrects blue-green.md, which said the image digest was the only side-distinguishing field in the measurement. That was true of the compose text and false of the manifest app_id hashes.

It is not an identifier. --cvm-id's help says it takes "UUID, app_id, instance_id, or name", but the CLI passes the value straight into the cvms/{id} path and its SDK schemas accept only id / uuid / app_id / instance_id — any name resolution is the API's, undocumented, and unverified. So names are resolved against phala cvms list and every later call uses the platform id, which the deploy output prints beside app_id.

The env separation

deploy.env feeds two different environments and they no longer bleed into each other:

  • toward the CVM it was already an allowlist — ten names, and nothing else can reach a container, because the compose interpolates a fixed set of ${…} references and allowed_envs is measured;
  • toward child processes it used to be export everything (inherited from switch.sh). That is fine for curl and openssl; it is not fine for the phala CLI, which has an env contract of its own. The Cloudflare token and the Prometheus password sat in the environment of every child, and a DEBUG=1 in deploy.env would have reached the CLI, where DEBUG=phala::api-client prints every HTTP request body.

Now four names a child genuinely needs are exported and the rest are plain shell variables. The script also warns on the two things a config file looks like it can do and cannot: a ZG_GATEWAY_* the compose does not reference (every other gateway setting is literal, measured text) and DEBUG.

Moving to another dstack cluster (README)

Changing GATEWAY_DOMAIN reads like a one-line edit. Three of the things it moves are verification inputs, so they get settled on a throwaway CVM before a cutover — the failure otherwise lands on users running pcverify, not on us:

  1. the new cluster's OS image needs an osimages.json entry, or the os-image check fails for every user (that entry is Pin dstack-0.5.9 in the OS-image allowlist #100 for dstack-0.5.9);
  2. its mr_config_id must still be the V1 layout, or compose_hash cannot be read out of a quote at all — a code change, not a redeploy;
  3. app_id must still be compose_hash's leading 20 bytes, or the record model and the standby probe stop meaning what they say.

Plus: the SNI allowlist is per-cluster and fails as a bare TLS error with DNS and certificates looking perfect; the serving alias names the cluster and both sides share it, so blue and green cannot straddle two clusters and a cluster move is a cutover with a DNS-shaped gap; certificates start over from an empty volume against a per-hostname rate limit; and every enforced check is fail-closed, so a blocked egress host looks exactly like a CVM that never becomes ready.

Self-review

A review of the branch found eight things, all fixed in the last commit — the ones worth naming here:

  • a failed phala cvms list was indistinguishable from a free name, so an API blip would have waved a duplicate of a measured name through the collision guard;
  • under --allow-duplicate-name the resolver returned the first match — usually the CVM already there — so the fallback could have probed the old healthy CVM and pronounced the new one ready;
  • the measured booleans were compared against literal true with nothing rejecting a near miss: PUBLIC_TCBINFO=True sent --no-public-tcbinfo while the plan printed True;
  • verify and status were documented in forms that always die (the first needs to know which manifest to compare byte-for-byte, the second needs --side).

Every path was exercised against a stub CLI: preflight, dry-run, deploy, the not-ready failure (exit 1, and it says the CVM is still running), name collision, ambiguous name, list failure, bad boolean, floating tag, wrong-side issuance, cluster mismatch, missing config. pcverify's flag combination used by verify was confirmed to be accepted (exit 1 on an unreachable host, not exit 2 for a caller mistake).

Not exercised against the real platform. Nobody has run this against Phala Cloud yet, so two things want checking on the first real run: where phala deploy --json puts app_id (the script scans for any 40-hex value and falls back to cvms get), and whether INSTANCE_TYPE on the new cluster can actually give the 32 GiB the compose's GOMEMLIMIT assumes.


Generated by Claude Code

Hongji Cai and others added 5 commits August 31, 2026 06:04
The release workflow produced the attested manifest and switch.sh moved
traffic between two CVMs; in between sat a dashboard form. That form is not
a convenience gap — allowed_envs and the public_logs / public_sysinfo /
public_tcbinfo / secure_time flags all land in the measured app-compose, so
every field in it is an unrecorded input to app_id, and two blue/green sides
can only be compared if they were filled in identically.

deploy.sh is that record. It takes a release's digest-pinned
docker-compose.release.yml (checked against the sha256 the release publishes,
and refused outright if any gateway-image line is still on a tag — the pin
covers cvm-identity too, which runs as root with the guest-agent socket),
hands the CLI a mode-600 env file rather than an argv full of secrets, reads
back the two DNS records whose absence fails silently, and waits on /readyz
by app-id before anything points at the new CVM. It never moves traffic:
that stays switch.sh's separate decision.

Also corrects the CLI: `phala cvm create` is gone in favour of
`phala deploy`, and --vcpu/--memory in favour of --instance-type.

The new README section is the other half. Moving GATEWAY_DOMAIN reads like a
one-line edit and is not: the new cluster's OS image needs an osimages.json
entry or pcverify FAILS for every user; its mr_config_id must still be the V1
layout or compose_hash cannot be read out of a quote at all; app_id must
still be compose_hash's leading 20 bytes or the record model and the standby
probe both stop meaning what they say. All three are verification inputs, so
they are settled on a throwaway CVM before a cutover, not after. And because
the serving alias names the cluster and both sides share it, blue and green
cannot straddle two clusters — a cluster move is a cutover with a DNS-shaped
gap, not a zero-downtime flip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sACS7WuB37x2tUMfsxvXi
dstack's app-compose carries a `name` field and app_id is that manifest's hash,
so the CVM name given at create time is measured — `pcverify` prints it as
`app name`, and the live deployment's is `0g-pc-gateway-a-1`. It reads like a
dashboard label and is not one: changing it is a different app_id, i.e. a new
CVM and a re-audit, never a rename.

So deploy.sh now names a CVM 0g-pc-gateway-<ZG_PROM_ENV>-<side> rather than
0g-pc-gateway-<side>. Both halves earn their place in a measured field: without
the env, a staging CVM and a mainnet one on the same build would hash to one
app_id, and nothing else in the manifest separates them — DOMAIN and the router
URL are ${…} references whose values are never measured. ZG_PROM_ENV already
carries exactly this distinction, so there is one place to state it.

And a name collision is now refused. The name is also the CLI's handle
(`--cvm-id` accepts one), so a second CVM under a live name leaves
`phala cvms delete --cvm-id …` addressing whichever the API picks — the failure
you least want to discover with a rollback in hand. --allow-duplicate-name is
the deliberate override, which is also how a replica of one side gets deployed.

blue-green.md said the image digest was the only side-distinguishing field in
the measurement. That was true of the compose TEXT and false of the manifest
app_id actually hashes: with per-side names the two sides differ by name even
before the image does, so "rehearse with two different builds" was never the
requirement it claimed to be. The scaling section gains the other half of the
same fact — replicas must share a name or they are not replicas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sACS7WuB37x2tUMfsxvXi
The two environments deploy.env feeds were separated in one direction only.
Toward the CVM it was already an allowlist — build_env_file writes ten names
and nothing else can reach a container, which is right, because the compose
interpolates a fixed set of ${…} references and allowed_envs is measured.
Toward child processes it was `export` everything, inherited from switch.sh.

That is fine for switch.sh, whose children are curl and openssl. It is not fine
here: our child is the phala CLI, which has an env contract of its own. So the
Cloudflare token and the Prometheus password were sitting in the environment of
every process this script starts, and a `DEBUG=1` in deploy.env — which reads
like it configures the script — would have reached the CLI, where
DEBUG=phala::api-client prints every HTTP request body. PRIVATE_KEY and
ETH_RPC_URL are the same shape of trap in the other direction.

Values are now plain shell variables, and exactly four names a child genuinely
needs (PHALA_CLOUD_API_KEY, PHALA_CLOUD_API_PREFIX, PRIVATE_KEY, ETH_RPC_URL)
are exported.

Also warns on the two things a config file cannot do but looks like it can: a
ZG_GATEWAY_* the compose does not reference (every other gateway setting is
literal, measured text — changing one is an app_id change, not a variable), and
DEBUG, which is now deliberately not passed on. Both would otherwise be
discovered by deploying and wondering why nothing changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sACS7WuB37x2tUMfsxvXi
`--cvm-id`'s help says it takes "UUID, app_id, instance_id, or name", and this
script believed it. The CLI passes that value straight into the `cvms/{id}` API
path and resolves no names itself; its SDK schemas accept only id, uuid, app_id
and instance_id. So name resolution, if it happens at all, is the API's and
undocumented — not something to build on.

The place it would have hurt is the collision check. A name that never resolves
answers "not found" every time, so the guard would have passed silently on
exactly the case it exists to catch, and the first symptom would be two CVMs
answering to one name with a rollback in hand.

Names are now resolved against `phala cvms list` — matched on the list's own
`name` field, with the platform id read back out — and every later call uses
that id. The deploy output prints it beside app_id, and the docs no longer tell
anyone to `phala cvms delete --cvm-id <name>`.

The measured-name half of the story is unaffected: that one rests on dstack's
AppCompose carrying a `name` field and on the live deployment's own pcverify
output (`app name 0g-pc-gateway-a-1`), not on anything the CLI claims. A name
is measured AND not an identifier — the two facts are independent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sACS7WuB37x2tUMfsxvXi
Eight things, in rough order of how much they would have cost:

A list that could not be READ was indistinguishable from a name that is free.
`cvm_ids_by_name` now dies on a failed `phala cvms list` instead of returning
nothing, because "the API did not answer" silently becoming "that measured name
is available" is the exact hole the list-based lookup exists to close.

Under --allow-duplicate-name the resolver returned the FIRST match, which is
usually the CVM that was already there — so the app_id fallback could have
probed the old healthy CVM and pronounced the new one ready, and the failure
message would have named the live replica's id to delete. An ambiguous name now
resolves to nothing, and the caller says which case it is in.

The five measured app-compose booleans were compared against literal `true`
with nothing rejecting a near miss: PUBLIC_TCBINFO=True sent
--no-public-tcbinfo while the plan printed `True`, turning off the guest-agent
route pcverify reads app-compose through — a silent app_id change that fails
code identity for every user. They are now validated to exactly true/false.

Two commands were documented in forms that always die: `verify` without
--release/--compose (it has to know which manifest to compare byte-for-byte)
and `status` without --side (that is half the CVM's name). The post-deploy
banner now prints `verify` with the flag THIS run used — the resolved release
tag, not `latest`, which by then may be a different release.

RELEASE_TAG was assigned inside a command substitution, so the plan never named
the release `--release latest` had resolved to. It travels through a file now.

`grep -n` prefixed line numbers before the ^-anchored gateway pattern filtered
them, so the "other unpinned images" warning re-listed the gateway lines it had
just approved. Filter first, number after.

And two empty-array expansions lacked the `${arr[@]+…}` guard that line 769
already had — `set -u` on bash < 4.4, which macOS still ships and this script
re-execs into.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sACS7WuB37x2tUMfsxvXi
@HONGJICAI
HONGJICAI marked this pull request as draft August 31, 2026 13:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant