diff --git a/.github/workflows/tvc-deploy.yml b/.github/workflows/tvc-deploy.yml new file mode 100644 index 00000000..2b529848 --- /dev/null +++ b/.github/workflows/tvc-deploy.yml @@ -0,0 +1,150 @@ +name: TVC Deploy + +# Deploy parser_app to TVC via the standalone tools/tvc-deploy helper: +# image-digest gate -> create -> approve -> poll-to-healthy -> set-live. +# +# Triggers: +# - workflow_dispatch: deploy with explicit inputs (use the dev TEST app for trials). +# - pull_request labeled `tvc-deploy-test`: CI validation deploy against the dev +# TEST app using repo vars.TVC_TEST_* (never the live app). +# Dev/staging only. Production deploys stay manual (see the deploy runbook). +# +# Required repo secrets: +# TVC_CI_OPERATOR_SEED - operator master seed; MUST be a member of the target +# app's manifest set (set for the test app today) +# TVC_ORG_ID - dev org id +# TVC_API_KEY_PUBLIC - dedicated CI dev API key (public) [needs provisioning] +# TVC_API_KEY_PRIVATE - dedicated CI dev API key (private) [needs provisioning] +# Required repo variables (PR-label test path): +# TVC_TEST_APP_ID, TVC_TEST_IMAGE_URL, TVC_TEST_EXPECTED_DIGEST, TVC_TEST_OPERATOR_ID + +on: + workflow_dispatch: + inputs: + app_id: + description: "TVC app id (use the dev TEST app for a safe trial)" + required: true + image_url: + description: "Pinned parser_app image, ghcr.io/...@sha256:..." + required: true + expected_digest: + description: "Expected pivot binary sha256 (hex, no 0x/sha256: prefix)" + required: true + operator_id: + description: "TVC operator id (must be in the app's manifest set)" + required: true + qos_version: + description: "QOS version" + required: false + default: "v2026.2.6" + host_ip: + description: "parser_app listen IP" + required: false + default: "0.0.0.0" + host_port: + description: "parser_app listen port" + required: false + default: "3000" + turnkey_client_version: + description: "turnkey-client image tag for the smoke check (approved version, not latest)" + required: false + default: "latest" + pull_request: + # Apply the `tvc-deploy-test` label to a PR to run a CI validation deploy + # against the dev TEST app (uses repo vars.TVC_TEST_*; never the live app). + types: [labeled] + +# Avoid overlapping deploys to the same target. +concurrency: + group: tvc-deploy-${{ github.ref }} + cancel-in-progress: false + +# Reads the repo + secrets, and pulls the smoke-test container from GHCR. +permissions: + contents: read + packages: read + +jobs: + deploy: + # Manual dispatch, or the `tvc-deploy-test` label on a PR (test app only). + if: >- + github.event_name == 'workflow_dispatch' || + github.event.label.name == 'tvc-deploy-test' + runs-on: ubuntu-latest + # Bounded: cargo install tvc (~minutes) + poll-to-healthy (up to 15m) + smoke. + timeout-minutes: 45 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@fb51252c7ba57d633bc668f941da052e410add48 # v1.13.0 + + - name: Install tvc CLI + run: cargo install tvc --version 0.7.0 --locked + + - name: Build deploy helper + run: cargo build --release --manifest-path tools/tvc-deploy/Cargo.toml + + - name: Write operator seed + env: + TVC_CI_OPERATOR_SEED: ${{ secrets.TVC_CI_OPERATOR_SEED }} + run: | + umask 077 + printf '%s' "$TVC_CI_OPERATOR_SEED" > "$RUNNER_TEMP/operator.seed" + + - name: Deploy + env: + # tvc reads auth from these (see `tvc deploy create --help`). + TVC_ORG_ID: ${{ secrets.TVC_ORG_ID }} + TVC_API_KEY_PUBLIC: ${{ secrets.TVC_API_KEY_PUBLIC }} + TVC_API_KEY_PRIVATE: ${{ secrets.TVC_API_KEY_PRIVATE }} + # Inputs via env (avoid shell-injection from interpolated ${{ }}). + # workflow_dispatch uses inputs; the PR-label path has none, so fall + # back to the test-app repo variables / defaults. + APP_ID: ${{ inputs.app_id || vars.TVC_TEST_APP_ID }} + IMAGE_URL: ${{ inputs.image_url || vars.TVC_TEST_IMAGE_URL }} + EXPECTED_DIGEST: ${{ inputs.expected_digest || vars.TVC_TEST_EXPECTED_DIGEST }} + OPERATOR_ID: ${{ inputs.operator_id || vars.TVC_TEST_OPERATOR_ID }} + QOS_VERSION: ${{ inputs.qos_version || 'v2026.2.6' }} + HOST_IP: ${{ inputs.host_ip || '0.0.0.0' }} + HOST_PORT: ${{ inputs.host_port || '3000' }} + run: | + ./tools/tvc-deploy/target/release/tvc-deploy deploy \ + --app-id "$APP_ID" \ + --image-url "$IMAGE_URL" \ + --expected-digest "$EXPECTED_DIGEST" \ + --operator-id "$OPERATOR_ID" \ + --operator-seed "$RUNNER_TEMP/operator.seed" \ + --qos-version "$QOS_VERSION" \ + --host-ip "$HOST_IP" \ + --host-port "$HOST_PORT" + + # Post-deploy smoke: against the live dev endpoint (/visualsign-dev), run + # `turnkey-client verify` on a V0+ALT tx and assert it both RENDERS and + # cryptographically VERIFIES (AWS Nitro attestation + enclave signature). + # The API key is reconstructed from the same TVC_API_KEY_* secrets. Pins an + # approved turnkey-client image version rather than :latest. + # NOTE: requires the turnkey-client image on GHCR (visualsign-turnkeyclient + # docker.yml / #23); until that's published this step can't pull the image. + - name: Smoke (dev verify endpoint) + if: success() + env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TVC_API_KEY_PUBLIC: ${{ secrets.TVC_API_KEY_PUBLIC }} + TVC_API_KEY_PRIVATE: ${{ secrets.TVC_API_KEY_PRIVATE }} + VSP_SMOKE_ORG: ${{ secrets.TVC_ORG_ID }} + # Pin the approved turnkey-client version (input -> repo var -> latest). + VSP_SMOKE_CLIENT_VERSION: ${{ inputs.turnkey_client_version || vars.TVC_SMOKE_CLIENT_VERSION || 'latest' }} + run: | + echo "$GHCR_TOKEN" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + umask 077 + mkdir -p "$HOME/.config/turnkey/keys" + printf '%s' "$TVC_API_KEY_PUBLIC" > "$HOME/.config/turnkey/keys/dev.public" + printf '%s:p256' "$TVC_API_KEY_PRIVATE" > "$HOME/.config/turnkey/keys/dev.private" + ./scripts/smoke.sh + + - name: Scrub secrets + if: always() + run: | + shred -u "$RUNNER_TEMP/operator.seed" 2>/dev/null || rm -f "$RUNNER_TEMP/operator.seed" + rm -rf "$HOME/.config/turnkey/keys" diff --git a/docs/specs/2026-06-29-prod-release-initiate-approve-design.md b/docs/specs/2026-06-29-prod-release-initiate-approve-design.md new file mode 100644 index 00000000..e490a387 --- /dev/null +++ b/docs/specs/2026-06-29-prod-release-initiate-approve-design.md @@ -0,0 +1,126 @@ +# Prod Release flow — initiate / approve / promote + +**Date:** 2026-06-29 +**Status:** Approved (design) +**Branches:** `feat/prod-release-flow` (stacked on `feat/tvc-deploy-helper`, PR #395, visualsign-parser); `feat/dev-path-and-container` (PR #23, visualsign-turnkeyclient) + +## Overview + +Today the `tvc-deploy` helper drives the entire dev deploy end to end: digest gate -> create -> approve -> poll-to-healthy -> set-live. That is fine for dev, where CI can hold an operator seed. For **production** we want **separation of duties**: whoever triggers the release from CI must never hold the quorum/operator key. CI only holds the Turnkey **API key** (enough to create / read status / set-live); a human **operator** approves the manifest out-of-band with their key. + +This splits the prod path into three steps across two manually-triggered workflows, plus a binary-identity check on the post-promote smoke so we can assert the live enclave is serving exactly the release we deployed. + +## Goals + +- Prod CI can **initiate** a deploy (create + digest gate) without any operator key. +- A human **operator** approves the created deployment with a key sourced from 1Password. +- Prod **set-live** is a separate, deliberate, manually-triggered step. +- The post-promote smoke **cryptographically pins** the served enclave to the exact binary we released. +- The existing dev flow (`TVC Deploy (Dev)`) is unchanged in behavior. + +## Non-goals / out of scope + +- **Staging** — deferred; only Dev (existing) and Prod (new) for now. +- Provisioning the prod **parse API key** and a prod-renderable **fixture** for the smoke (noted as a prerequisite, tracked separately). +- Automatic rollback — reverting a prod deploy means re-deploying the prior image (manual). + +## Architecture + +### Two repos + +- **visualsign-parser** — the `tvc-deploy` helper, `smoke.sh`, and the workflows. +- **visualsign-turnkeyclient** — the `verify` command, which gains the pivot-hash pin (`--expected-pivot-hash`). + +### Flow + +``` +Release (CI, prod API key) Operator (human, quorum key) Promote (CI, prod API key) + tvc-deploy initiate tvc-deploy approve tvc-deploy promote + - digest gate - re-run digest gate - poll-to-healthy + - tvc deploy create - tvc deploy approve - set-live + - print deploy ID --> (seed from 1Password) --> - smoke: verify --canonical + --expected-pivot-hash +``` + +## Component 1 — helper subcommands (`tools/tvc-deploy`) + +Refactor the current monolithic `deploy()` into three composable subcommands. The existing digest-gate, config-assembly, and polling logic is **extracted, not duplicated**. + +- **`initiate --app-id --image-url --expected-digest [--qos-version --host-ip --host-port]`** + 1. `validate_digest` + `verify_image_digest` (the existing image-derived digest gate). + 2. Assemble the deploy config (gRPC health) and `tvc deploy create`. + 3. Parse and print the **deployment ID** (existing `parse_after "Deployment ID:"`). + Requires only the Turnkey API key. No operator seed, no approve, no set-live. + +- **`approve --deploy-id --operator-id --image-url --expected-digest [--operator-seed]`** + 1. **Re-run `verify_image_digest`** so the approver independently confirms the image's `/parser_app` sha256 equals `--expected-digest` before signing. + 2. `tvc deploy approve --deploy-id … --operator-id … [--operator-seed … | logged-in operator]`. + Run locally by the human operator with their key (seed resolution unchanged: flag -> `TVC_CI_OPERATOR_SEED` -> logged-in operator). + +- **`promote --app-id --deploy-id`** + 1. `poll_health` to healthy (existing). + 2. `set_live` (existing, including the "already live" + transient-retry handling). + +- **`deploy …` (dev, unchanged CLI/behavior)** = `initiate` -> `approve` -> `promote` composed internally. + +## Component 2 — pivot-hash pin (`turnkey-client verify`) + +The post-promote smoke must assert the live enclave serves the binary we released. The deployed identity is the QoS manifest's `Pivot.Hash`, which equals the sha256 of the pivot `/parser_app` binary — i.e. the same value we pass as `--expected-digest` (TVC's `expectedPivotDigest`). It is attestation-bound: the served manifest's hash matches the attestation UserData, and that manifest contains `Pivot.Hash`. + +The existing `verify` flags do **not** pin this: `--qos-manifest-hex` / `--pivot-binary-hash-hex` compare against UserData (the *manifest* hash), and the JSON output's `pivotBinaryHash` is currently mis-aliased to the manifest hash. So `verify` gains: + +- **`--expected-pivot-hash `** — decode the (attestation-bound) manifest and assert `manifest.Pivot.Hash == `; fail verification on mismatch. +- Surface the **real** `Pivot.Hash` in the JSON output (fix the alias) so it is observable. + +`verify` already decodes the manifest (`manifest.Pivot.Hash`, `manifest/types.go`), so this is a small, well-scoped addition. Stacks on PR #23. + +## Component 3 — smoke canonical-path + pin (`scripts/smoke.sh`) + +- **Canonical path:** add a `--canonical` flag (env `VSP_SMOKE_CANONICAL=1`) that runs `verify` **without** `--dev-path`, targeting the prod `/visualsign` endpoint. Dev usage is unchanged (defaults to the dev path). +- **Pin passthrough:** add `--expected-pivot-hash ` / `VSP_SMOKE_EXPECTED_PIVOT_HASH` that forwards to `verify --expected-pivot-hash`. The existing `.valid / .attestationValid / .signatureValid` assertions plus the new pin make a prod PASS mean "genuine enclave, valid signature, renders, **and** running exactly the released binary." +- Prod org/app supplied via existing `VSP_SMOKE_ORG` / related env. + +## Component 4 — workflows (`.github/workflows`) + +- **TVC Deploy (Dev)** — existing `tvc-deploy.yml`, renamed (name, and filename to `tvc-deploy-dev.yml`). Behavior unchanged; auto-drives dev via the composed `deploy`. +- **Release** (`release.yml`, prod, `workflow_dispatch`) — inputs: `app_id`, `image_url`, `expected_digest`, `qos_version`, `host_ip`, `host_port`. Steps: install `tvc` (pinned), build helper, run `tvc-deploy initiate …`, surface the deploy ID in the job summary. Auth: prod `TVC_ORG_ID` / `TVC_API_KEY_*` only — **no operator seed**. +- **Promote** (`promote.yml`, prod, `workflow_dispatch`) — inputs: `app_id`, `deploy_id`, `expected_digest`, `turnkey_client_version`. Steps: `tvc-deploy promote …`, then `smoke.sh --canonical --expected-pivot-hash …` against the prod endpoint. Auth: prod API key only. Concurrency-guarded so two promotes can't race. +- **Operator approval** (out-of-band, runbook-documented) — operator pulls the **prod** operator seed from 1Password (its own item, distinct from `Turnkey VSP Dev Deployment Operator Key`), confirms the deploy ID from the Release run, and runs `tvc-deploy approve …` (re-verifies the digest, then signs). + +## Separation of duties / secrets + +- CI (Release + Promote) holds the prod Turnkey **API key** — can create / status / set-live, **cannot** approve a manifest. +- The **quorum/operator key** never enters CI; it lives in 1Password and is used only by the human approver on their machine. + +## Error handling & idempotency + +- `initiate` / `approve`: the digest gate aborts before any irreversible action on mismatch. +- `approve`: idempotent against an already-approved manifest (delegated to `tvc`). +- `promote`: `set_live` already treats "already live" as success and retries transient settling errors; `poll_health` is bounded by timeout. Re-runnable. +- Release/Promote: concurrency guards keyed by app so overlapping runs don't race. + +## Testing + +- **Rust:** unit tests for the new subcommand arg parsing; a test that `deploy` still composes `initiate -> approve -> promote`. clippy `-D warnings`, fmt. +- **Go (turnkey-client):** TDD `--expected-pivot-hash` — a manifest whose `Pivot.Hash` matches passes; a mismatch fails verification. Reuse the existing manifest fixtures. +- **smoke.sh:** extend the existing `TURNKEY_CLIENT`-stub bash harness to assert the new flags — `--canonical` omits `--dev-path`, and `--expected-pivot-hash` forwards to `verify`. shellcheck clean. +- **Manual:** prove `initiate -> approve -> promote` end-to-end against the **dev test app** before any prod run. + +## Rollout / stacking + +- `verify --expected-pivot-hash` lands on `feat/dev-path-and-container` (PR #23). +- Helper subcommands, `smoke.sh`, and the workflows land on `feat/prod-release-flow` (stacked on PR #395). +- The runbook (Notion sub-page) gains the prod initiate -> approve -> promote procedure. + +## Open items (resolve early in implementation) + +- Confirm the `tvc` CLI version in use prints the deployment ID in the format `initiate` parses (existing `parse_after "Deployment ID:"` already handles dev). +- Confirm the prod Turnkey **org id / API key** and the prod **operator id** + 1Password item; confirm the prod **app id** and `/visualsign` endpoint app for the smoke. +- Provision a prod parse API key and a prod-renderable fixture for the Promote smoke (prerequisite). + +## References + +- visualsign-parser PR #395 (tvc-deploy helper + dev smoke). +- visualsign-turnkeyclient PR #23 (`--dev-path`/`--chain` + container; `verify --expected-pivot-hash` to be added here). +- Notion: "Runbook — parser_app dev deploy via tvc-deploy helper + verify smoke" (parent: "Runbook — Turnkey TVC deployment for visualsign-parser"). +- Linear PRS-515 (TVC deploy runbook), PRS-516 (automate dev/staging TVC deploys). diff --git a/docs/specs/2026-06-29-prod-release-initiate-approve-plan.md b/docs/specs/2026-06-29-prod-release-initiate-approve-plan.md new file mode 100644 index 00000000..30a38a20 --- /dev/null +++ b/docs/specs/2026-06-29-prod-release-initiate-approve-plan.md @@ -0,0 +1,896 @@ +# Prod Release flow (initiate / approve / promote) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split prod parser_app deploys into CI-driven `initiate`/`promote` steps and a human `approve` step so CI never holds the operator key, and pin the post-promote smoke to the exact released binary. + +**Architecture:** A `verify --expected-pivot-hash` check is added to the Go turnkey-client so the smoke can assert the live enclave's manifest `Pivot.Hash` equals the deployed digest. The Rust `tvc-deploy` helper is refactored behind a `TvcOps` trait and split into `initiate`/`approve`/`promote` subcommands (dev `deploy` composes them). Two manually-triggered prod workflows (Release, Promote) plus the renamed dev workflow drive it. + +**Tech Stack:** Go (urfave/cli v3, near/borsh-go) in visualsign-turnkeyclient; Rust 2024 (xshell, anyhow, lexopt, serde_json, qos_p256) in visualsign-parser `tools/tvc-deploy`; Bash + jq for `scripts/smoke.sh`; GitHub Actions YAML. + +## Global Constraints + +- Rust: workspace lints deny `unwrap_used`/`expect_used`/`panic`, forbid `unsafe`; test modules add `#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]`. Use inline format strings. Place `use` at the top of the module/test module. Every Rust task ends green on `cargo clippy --all-targets -- -D warnings` and `cargo fmt`. +- ASCII only in helper/script output (`>=` not `≥`, `->` not `→`). +- Go: `gofmt`, `go vet ./...`, `go test ./...` all clean. +- CI separation of duties: Release and Promote workflows carry only the Turnkey API key (`TVC_ORG_ID`/`TVC_API_KEY_PUBLIC`/`TVC_API_KEY_PRIVATE`). The operator/quorum seed is NEVER referenced in a workflow. +- `tvc` CLI pinned `--version 0.7.0 --locked` in all workflows. +- Smoke exit codes unchanged: 0 = pass/skip, 1 = up-but-failed, 2 = harness could not run the client. +- Prod placeholder values in workflows use `vars.TVC_PROD_*` / `secrets.TVC_PROD_*`; do not hardcode prod ids. + +--- + +## Phase 1 — turnkey-client: `verify --expected-pivot-hash` + +Repo: `visualsign-turnkeyclient`, branch `feat/dev-path-and-container` (PR #23). Env for all commands: `export PATH="/usr/local/go/bin:$HOME/go/bin:$PATH" GOPATH="$HOME/go" GOFLAGS=-mod=mod`. + +### Task 1: Pivot-hash check in verify + +**Files:** +- Create: `verify/pivot_hash.go` +- Test: `verify/pivot_hash_test.go` +- Modify: `verify/types.go` (add `ExpectedPivotHashHex` to `VerifyRequest`) +- Modify: `verify/service.go` (set `result.PivotBinaryHash` from the manifest; call the check) +- Modify: `cmd/verify.go` (add `--expected-pivot-hash` flag, thread into the request) + +**Interfaces:** +- Produces: `func CheckExpectedPivotHash(m *manifest.Manifest, expectedHex string) error` — returns nil when `expectedHex` is empty or equals `hex(m.Pivot.Hash)`; error otherwise. +- Consumes: `manifest.Manifest` (`Pivot.Hash` is `manifest.Hash256` = `[32]byte`, `manifest/types.go`). + +- [ ] **Step 1: Write the failing test** + +```go +// verify/pivot_hash_test.go +package verify + +import ( + "testing" + + "github.com/anchorageoss/visualsign-turnkeyclient/manifest" +) + +func mkManifest(b byte) *manifest.Manifest { + var m manifest.Manifest + for i := range m.Pivot.Hash { + m.Pivot.Hash[i] = b + } + return &m +} + +func TestCheckExpectedPivotHash(t *testing.T) { + m := mkManifest(0xab) // Pivot.Hash = 32 * 0xab + full := "abababababababababababababababababababababababababababababababab" + + if err := CheckExpectedPivotHash(m, ""); err != nil { + t.Fatalf("empty expected should pass, got %v", err) + } + if err := CheckExpectedPivotHash(m, full); err != nil { + t.Fatalf("matching hash should pass, got %v", err) + } + if err := CheckExpectedPivotHash(m, "ABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABABAB"+"AB"); err != nil { + t.Fatalf("case-insensitive match should pass, got %v", err) + } + if err := CheckExpectedPivotHash(m, "00"+full[2:]); err == nil { + t.Fatal("mismatched hash should fail") + } + if err := CheckExpectedPivotHash(nil, full); err == nil { + t.Fatal("nil manifest with expected set should fail") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./verify/ -run TestCheckExpectedPivotHash -v` +Expected: FAIL (undefined: `CheckExpectedPivotHash`). + +- [ ] **Step 3: Write minimal implementation** + +```go +// verify/pivot_hash.go +package verify + +import ( + "encoding/hex" + "fmt" + "strings" + + "github.com/anchorageoss/visualsign-turnkeyclient/manifest" +) + +// CheckExpectedPivotHash asserts the manifest's pivot binary hash equals the +// expected hex (the value deployed as expectedPivotDigest). A blank expected +// disables the check. +func CheckExpectedPivotHash(m *manifest.Manifest, expectedHex string) error { + if expectedHex == "" { + return nil + } + if m == nil { + return fmt.Errorf("cannot verify pivot hash: no manifest decoded") + } + actual := hex.EncodeToString(m.Pivot.Hash[:]) + if !strings.EqualFold(actual, expectedHex) { + return fmt.Errorf("pivot hash mismatch: manifest %s != expected %s", actual, expectedHex) + } + return nil +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./verify/ -run TestCheckExpectedPivotHash -v` +Expected: PASS. + +- [ ] **Step 5: Surface the real pivot hash and wire the request** + +In `verify/types.go`, add to `VerifyRequest`: + +```go + ExpectedPivotHashHex string +``` + +In `verify/service.go`, where the manifest is decoded and `result.PivotBinaryHash` is currently assigned (it is presently set to the manifest hash — correct it), set it from the manifest and run the check: + +```go + // result.Manifest is the decoded manifest; set the real pivot binary hash. + if result.Manifest != nil { + result.PivotBinaryHash = hex.EncodeToString(result.Manifest.Pivot.Hash[:]) + } + if err := CheckExpectedPivotHash(result.Manifest, req.ExpectedPivotHashHex); err != nil { + return nil, err + } +``` + +(Add `"encoding/hex"` to the `verify/service.go` imports if not present.) + +In `cmd/verify.go`, add the flag to the `Flags` slice: + +```go + &cli.StringFlag{ + Name: "expected-pivot-hash", + Usage: "Expected pivot binary hash (hex). Fails verification unless the manifest's pivot hash matches.", + }, +``` + +and thread it into the `VerifyRequest` built in `runVerifyCommand`: + +```go + ExpectedPivotHashHex: cmd.String("expected-pivot-hash"), +``` + +- [ ] **Step 6: Run the full suite + vet** + +Run: `go test ./... && go vet ./...` +Expected: all packages `ok`, no vet output. + +- [ ] **Step 7: Commit** + +```bash +git add verify/pivot_hash.go verify/pivot_hash_test.go verify/types.go verify/service.go cmd/verify.go +git commit -m "feat: verify --expected-pivot-hash pins the manifest pivot binary hash + +Co-Authored-By: Claude Opus 4.8 " +``` + +- [ ] **Step 8: Manual confirmation against dev (optional, needs keys)** + +Run (with the local binary built and dev keys present): +```bash +make build && BIN=bin/visualsign-turnkeyclient +PAYLOAD="$(tr -d '[:space:]' < ../visualsign-parser/testdata/solana_v0_alt.b64)" +$BIN verify --dev-path --expected-pivot-hash ce9f2733d412b9e0e3e5d582f1ab3c399910c311985ab6e208da78726bbe7649 \ + --host https://api.turnkey.com --organization-id d7f51c3d-fb9d-47c1-9b2e-a02b1cd5ff14 \ + --key-name dev --unsigned-payload "$PAYLOAD" --chain CHAIN_SOLANA; echo "exit=$?" +``` +Expected: exit 0 with the matching pivot hash; a wrong `--expected-pivot-hash` exits non-zero with "pivot hash mismatch". (`ce9f2733...` is the dev app's current pivot hash; if it changed, read the current value from `.pivotBinaryHash` in the output.) + +--- + +## Phase 2 — helper: `TvcOps` trait + initiate/approve/promote + +Repo: `visualsign-parser`, branch `feat/prod-release-flow`. File: `tools/tvc-deploy/src/main.rs`. Env: `export PATH="$HOME/.cargo/bin:$PATH"`. Build/test from `tools/tvc-deploy`. + +### Task 2: Introduce `TvcOps` trait and pure config builder; refactor `deploy` + +This refactor preserves dev behavior while making the orchestration unit-testable. The trait abstracts every external (tvc/docker) call; the subcommand functions become pure orchestration over the trait + local fs. + +**Files:** +- Modify: `tools/tvc-deploy/src/main.rs` +- Test: `tools/tvc-deploy/src/main.rs` (`#[cfg(test)] mod tests`) + +**Interfaces:** +- Produces: + - `fn build_deploy_config(app_id, qos, image, host_ip, host_port: u16, digest: &str) -> serde_json::Value` + - `trait TvcOps { fn verify_image_digest(&self, image: &str, expected: &str) -> Result<()>; fn create(&self, cfg_path: &Path) -> Result; fn approve(&self, deploy_id: &str, operator_id: &str, seed: Option<&Path>) -> Result<()>; fn poll_health(&self, app_id: &str, deploy_id: &str, timeout: Duration) -> Result<()>; fn set_live(&self, deploy_id: &str, timeout: Duration) -> Result<()>; }` + - `struct RealTvc<'a> { sh: &'a Shell }` implementing `TvcOps` by moving the existing `cmd!` calls into the trait methods. + - `fn do_deploy(ops: &impl TvcOps, flags, ...) -> Result<()>` composing initiate -> approve -> promote (the existing dev behavior). + +- [ ] **Step 1: Write the failing test (config builder + recording fake compose order)** + +```rust +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use std::cell::RefCell; + + #[derive(Default)] + struct RecordingTvc { + calls: RefCell>, + } + impl TvcOps for RecordingTvc { + fn verify_image_digest(&self, _image: &str, _expected: &str) -> Result<()> { + self.calls.borrow_mut().push("verify_image_digest".into()); + Ok(()) + } + fn create(&self, _cfg_path: &Path) -> Result { + self.calls.borrow_mut().push("create".into()); + Ok("deploy-123".into()) + } + fn approve(&self, deploy_id: &str, _operator_id: &str, _seed: Option<&Path>) -> Result<()> { + self.calls.borrow_mut().push(format!("approve:{deploy_id}")); + Ok(()) + } + fn poll_health(&self, _app: &str, deploy_id: &str, _t: Duration) -> Result<()> { + self.calls.borrow_mut().push(format!("poll:{deploy_id}")); + Ok(()) + } + fn set_live(&self, deploy_id: &str, _t: Duration) -> Result<()> { + self.calls.borrow_mut().push(format!("set_live:{deploy_id}")); + Ok(()) + } + } + + fn flags(pairs: &[(&str, &str)]) -> HashMap { + pairs.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect() + } + + #[test] + fn config_has_grpc_health_and_digest() { + let cfg = build_deploy_config("app", "v1", "img", "0.0.0.0", 3000, "deadbeef"); + assert_eq!(cfg["healthCheckType"], "TVC_HEALTH_CHECK_TYPE_GRPC"); + assert_eq!(cfg["expectedPivotDigest"], "deadbeef"); + assert_eq!(cfg["pivotPath"], "/parser_app"); + assert_eq!(cfg["healthCheckPort"], 3000); + } + + #[test] + fn deploy_runs_gate_create_approve_poll_setlive_in_order() { + let ops = RecordingTvc::default(); + let f = flags(&[ + ("app-id", "app"), + ("image-url", "img"), + ("expected-digest", "deadbeef"), + ("operator-id", "op"), + ("operator-seed", "/tmp/seed"), + ]); + do_deploy(&ops, &f).unwrap(); + assert_eq!( + *ops.calls.borrow(), + vec![ + "verify_image_digest", + "create", + "approve:deploy-123", + "poll:deploy-123", + "set_live:deploy-123", + ] + ); + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cargo test -p tvc-deploy 2>&1 | tail -20` (from `tools/tvc-deploy`: `cargo test`) +Expected: FAIL — `build_deploy_config`, `TvcOps`, `do_deploy` not found. + +- [ ] **Step 3: Implement the trait, RealTvc, builder, and do_deploy** + +Extract the JSON assembly from today's `deploy` into `build_deploy_config`: + +```rust +fn build_deploy_config( + app_id: &str, + qos: &str, + image: &str, + host_ip: &str, + host_port: u16, + digest: &str, +) -> serde_json::Value { + serde_json::json!({ + "appId": app_id, + "qosVersion": qos, + "pivotContainerImageUrl": image, + "pivotPath": "/parser_app", + "pivotArgs": ["--host-ip", host_ip, "--host-port", host_port.to_string()], + "expectedPivotDigest": digest, + "debugMode": false, + "healthCheckType": "TVC_HEALTH_CHECK_TYPE_GRPC", + "healthCheckPort": host_port, + "publicIngressPort": host_port, + }) +} +``` + +Define the trait and move the existing shell-outs into `RealTvc`: + +```rust +trait TvcOps { + fn verify_image_digest(&self, image: &str, expected: &str) -> Result<()>; + fn create(&self, cfg_path: &Path) -> Result; + fn approve(&self, deploy_id: &str, operator_id: &str, seed: Option<&Path>) -> Result<()>; + fn poll_health(&self, app_id: &str, deploy_id: &str, timeout: Duration) -> Result<()>; + fn set_live(&self, deploy_id: &str, timeout: Duration) -> Result<()>; +} + +struct RealTvc<'a> { + sh: &'a Shell, +} + +impl TvcOps for RealTvc<'_> { + fn verify_image_digest(&self, image: &str, expected: &str) -> Result<()> { + verify_image_digest(self.sh, image, expected) // existing free fn + } + fn create(&self, cfg_path: &Path) -> Result { + let created = cmd!(self.sh, "tvc deploy create --config-file {cfg_path}") + .read() + .context("tvc deploy create")?; + parse_after(&created, "Deployment ID:") + .with_context(|| format!("no deployment id in create output:\n{created}")) + } + fn approve(&self, deploy_id: &str, operator_id: &str, seed: Option<&Path>) -> Result<()> { + let mut seed_args: Vec = Vec::new(); + if let Some(p) = seed { + seed_args.push("--operator-seed".into()); + seed_args.push(p.into()); + } + cmd!(self.sh, "tvc deploy approve --deploy-id {deploy_id} --operator-id {operator_id} {seed_args...} --dangerous-skip-interactive") + .run() + .context("tvc deploy approve") + } + fn poll_health(&self, app_id: &str, deploy_id: &str, timeout: Duration) -> Result<()> { + poll_health(self.sh, app_id, deploy_id, timeout) // existing free fn + } + fn set_live(&self, deploy_id: &str, timeout: Duration) -> Result<()> { + set_live(self.sh, deploy_id, timeout) // existing free fn + } +} +``` + +Add the composed dev deploy (operator seed resolution unchanged via `resolve_seed_file`): + +```rust +fn do_deploy(ops: &impl TvcOps, flags: &HashMap) -> Result<()> { + let app_id = req(flags, "app-id")?; + let operator_id = req(flags, "operator-id")?; + let seed = resolve_seed_file(flags)?; // Option<(PathBuf, bool)> + let deploy_id = initiate(ops, flags)?; + let seed_path = seed.as_ref().map(|(p, _)| p.as_path()); + ops.approve(&deploy_id, operator_id, seed_path)?; + println!("approved manifest for {deploy_id}"); + if let Some((path, true)) = &seed { + let _ = std::fs::remove_file(path); + } + ops.poll_health(app_id, &deploy_id, POLL_TIMEOUT)?; + ops.set_live(&deploy_id, SETLIVE_TIMEOUT)?; + println!("deployment {deploy_id} is healthy and live"); + Ok(()) +} +``` + +Update `run()` so `"deploy" => do_deploy(&RealTvc { sh: &sh }, &flags)`. Keep `verify_image_digest`, `poll_health`, `set_live`, `parse_after`, `resolve_seed_file`, `temp_path`, `build_deploy_config`, `validate_digest` as free functions. (`initiate` is added in Task 3; for this task, inline the gate+create into `do_deploy` or land Task 3 first — implement `initiate` here as part of the refactor so `do_deploy` can call it.) + +- [ ] **Step 4: Run tests to verify pass** + +Run (from `tools/tvc-deploy`): `cargo test` +Expected: PASS (`config_has_grpc_health_and_digest`, `deploy_runs_gate_create_approve_poll_setlive_in_order`). + +- [ ] **Step 5: clippy + fmt** + +Run: `cargo fmt && cargo clippy --all-targets -- -D warnings` +Expected: no warnings. + +- [ ] **Step 6: Commit** + +```bash +git add tools/tvc-deploy/src/main.rs +git commit -m "refactor(tools): TvcOps trait + pure config builder behind tvc-deploy + +Co-Authored-By: Claude Opus 4.8 " +``` + +### Task 3: `initiate` subcommand + +**Files:** Modify/Test: `tools/tvc-deploy/src/main.rs` + +**Interfaces:** +- Produces: `fn initiate(ops: &impl TvcOps, flags: &HashMap) -> Result` — runs `validate_digest`, `ops.verify_image_digest`, writes the config temp file, `ops.create`, prints and returns the deploy id. No approve/poll/set_live. + +- [ ] **Step 1: Write the failing test** + +```rust + #[test] + fn initiate_runs_only_gate_and_create() { + let ops = RecordingTvc::default(); + let f = flags(&[ + ("app-id", "app"), + ("image-url", "img"), + ("expected-digest", "deadbeef"), + ]); + let id = initiate(&ops, &f).unwrap(); + assert_eq!(id, "deploy-123"); + assert_eq!(*ops.calls.borrow(), vec!["verify_image_digest", "create"]); + } + + #[test] + fn initiate_rejects_bad_digest() { + let ops = RecordingTvc::default(); + let f = flags(&[("app-id", "a"), ("image-url", "i"), ("expected-digest", "xyz")]); + assert!(initiate(&ops, &f).is_err()); + assert!(ops.calls.borrow().is_empty()); + } +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cargo test initiate` +Expected: FAIL (if `initiate` not yet present) or compile error. + +- [ ] **Step 3: Implement `initiate`** + +```rust +fn initiate(ops: &impl TvcOps, flags: &HashMap) -> Result { + let app_id = req(flags, "app-id")?; + let image = req(flags, "image-url")?; + let digest = req(flags, "expected-digest")?; + let qos = flags.get("qos-version").map(String::as_str).unwrap_or("v2026.2.6"); + let host_ip = flags.get("host-ip").map(String::as_str).unwrap_or("0.0.0.0"); + let host_port: u16 = match flags.get("host-port") { + Some(s) => s.parse().with_context(|| format!("invalid --host-port: {s}"))?, + None => 3000, + }; + validate_digest(digest)?; + ops.verify_image_digest(image, digest)?; + let cfg = build_deploy_config(app_id, qos, image, host_ip, host_port, digest); + let cfg_path = temp_path("tvc-deploy", "json"); + std::fs::write(&cfg_path, serde_json::to_vec_pretty(&cfg)?) + .with_context(|| format!("write {}", cfg_path.display()))?; + let deploy_id = ops.create(&cfg_path); + let _ = std::fs::remove_file(&cfg_path); + let deploy_id = deploy_id?; + println!("created deployment {deploy_id}"); + Ok(deploy_id) +} +``` + +Add `"initiate" => { initiate(&RealTvc { sh: &sh }, &flags).map(|_| ()) }` to `run()`'s match. Update `USAGE` with the `initiate` line. + +- [ ] **Step 4: Run tests to verify pass** + +Run: `cargo test` +Expected: PASS. + +- [ ] **Step 5: clippy + fmt + commit** + +```bash +cargo fmt && cargo clippy --all-targets -- -D warnings +git add tools/tvc-deploy/src/main.rs +git commit -m "feat(tools): tvc-deploy initiate subcommand (digest gate + create) + +Co-Authored-By: Claude Opus 4.8 " +``` + +### Task 4: `promote` subcommand + +**Files:** Modify/Test: `tools/tvc-deploy/src/main.rs` + +**Interfaces:** +- Produces: `fn promote(ops: &impl TvcOps, flags: &HashMap) -> Result<()>` — `req` `app-id` + `deploy-id`, then `ops.poll_health` then `ops.set_live`. + +- [ ] **Step 1: Write the failing test** + +```rust + #[test] + fn promote_polls_then_sets_live() { + let ops = RecordingTvc::default(); + let f = flags(&[("app-id", "app"), ("deploy-id", "deploy-9")]); + promote(&ops, &f).unwrap(); + assert_eq!(*ops.calls.borrow(), vec!["poll:deploy-9", "set_live:deploy-9"]); + } +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cargo test promote` +Expected: FAIL (`promote` not found). + +- [ ] **Step 3: Implement `promote`** + +```rust +fn promote(ops: &impl TvcOps, flags: &HashMap) -> Result<()> { + let app_id = req(flags, "app-id")?; + let deploy_id = req(flags, "deploy-id")?; + ops.poll_health(app_id, deploy_id, POLL_TIMEOUT)?; + ops.set_live(deploy_id, SETLIVE_TIMEOUT)?; + println!("deployment {deploy_id} is healthy and live"); + Ok(()) +} +``` + +Add `"promote" => promote(&RealTvc { sh: &sh }, &flags)` to `run()`. Update `USAGE`. + +- [ ] **Step 4: Run tests + clippy + fmt + commit** + +```bash +cargo test && cargo fmt && cargo clippy --all-targets -- -D warnings +git add tools/tvc-deploy/src/main.rs +git commit -m "feat(tools): tvc-deploy promote subcommand (poll-to-healthy + set-live) + +Co-Authored-By: Claude Opus 4.8 " +``` + +### Task 5: `approve` subcommand (re-verify then approve) + +**Files:** Modify/Test: `tools/tvc-deploy/src/main.rs` + +**Interfaces:** +- Produces: `fn approve(ops: &impl TvcOps, flags: &HashMap) -> Result<()>` — `req` `deploy-id`, `operator-id`, `image-url`, `expected-digest`; runs `validate_digest`, `ops.verify_image_digest` (independent re-verify), then `ops.approve` with the resolved seed; cleans up an env-sourced temp seed. + +- [ ] **Step 1: Write the failing test** + +```rust + #[test] + fn approve_reverifies_then_approves() { + let ops = RecordingTvc::default(); + let f = flags(&[ + ("deploy-id", "deploy-7"), + ("operator-id", "op"), + ("image-url", "img"), + ("expected-digest", "deadbeef"), + ("operator-seed", "/tmp/seed"), + ]); + approve(&ops, &f).unwrap(); + assert_eq!(*ops.calls.borrow(), vec!["verify_image_digest", "approve:deploy-7"]); + } +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cargo test approve_reverifies` +Expected: FAIL (`approve` not found). + +- [ ] **Step 3: Implement `approve`** + +```rust +fn approve(ops: &impl TvcOps, flags: &HashMap) -> Result<()> { + let deploy_id = req(flags, "deploy-id")?; + let operator_id = req(flags, "operator-id")?; + let image = req(flags, "image-url")?; + let digest = req(flags, "expected-digest")?; + validate_digest(digest)?; + ops.verify_image_digest(image, digest)?; // independent confirmation before signing + let seed = resolve_seed_file(flags)?; + let result = ops.approve(deploy_id, operator_id, seed.as_ref().map(|(p, _)| p.as_path())); + if let Some((path, true)) = &seed { + let _ = std::fs::remove_file(path); + } + result?; + println!("approved manifest for {deploy_id}"); + Ok(()) +} +``` + +Add `"approve" => approve(&RealTvc { sh: &sh }, &flags)` to `run()`. Update `USAGE` with the `approve` line, documenting that the seed resolves flag -> `TVC_CI_OPERATOR_SEED` -> logged-in operator. + +- [ ] **Step 4: Run tests + clippy + fmt + commit** + +```bash +cargo test && cargo fmt && cargo clippy --all-targets -- -D warnings +git add tools/tvc-deploy/src/main.rs +git commit -m "feat(tools): tvc-deploy approve subcommand (re-verify digest, then approve) + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Phase 3 — smoke.sh: canonical path + pivot-hash pin + +Repo: `visualsign-parser`, file `scripts/smoke.sh`. Tests reuse the scratch stub harness pattern (a stub `TURNKEY_CLIENT` recording its args). Place the test under `tools/tvc-deploy/`-style scratch is not committed; instead commit a small bats-free bash test at `scripts/test_smoke_args.sh`. + +### Task 6: `--canonical` and `--expected-pivot-hash` + +**Files:** +- Modify: `scripts/smoke.sh` +- Create: `scripts/test_smoke_args.sh` + +**Interfaces:** +- Produces: smoke flags `--canonical` (env `VSP_SMOKE_CANONICAL=1`) — omit `--dev-path`; `--expected-pivot-hash ` (env `VSP_SMOKE_EXPECTED_PIVOT_HASH`) — forward `--expected-pivot-hash` to `verify`. + +- [ ] **Step 1: Write the failing test** + +```bash +#!/usr/bin/env bash +# scripts/test_smoke_args.sh — asserts smoke.sh forwards --canonical / --expected-pivot-hash. +set -uo pipefail +SMOKE="$(cd "$(dirname "$0")" && pwd)/smoke.sh" +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT +ARGFILE="$TMP/args" +fails=0 + +cat >"$TMP/client" < "$ARGFILE" +echo "=== VERIFICATION COMPLETE ===" >&2 +printf '%s' '{"signablePayload":"ok","valid":true,"attestationValid":true,"signatureValid":true,"moduleId":"m"}' +EOF +chmod +x "$TMP/client" + +# Default dev run: includes --dev-path, no --expected-pivot-hash. +TURNKEY_CLIENT="$TMP/client" "$SMOKE" >/dev/null 2>&1 +grep -qx -- "--dev-path" "$ARGFILE" || { echo "FAIL: default should pass --dev-path"; fails=$((fails+1)); } +grep -qx -- "--expected-pivot-hash" "$ARGFILE" && { echo "FAIL: default should not pin"; fails=$((fails+1)); } + +# Canonical + pin: no --dev-path, forwards --expected-pivot-hash . +TURNKEY_CLIENT="$TMP/client" "$SMOKE" --canonical --expected-pivot-hash deadbeef >/dev/null 2>&1 +grep -qx -- "--dev-path" "$ARGFILE" && { echo "FAIL: --canonical should omit --dev-path"; fails=$((fails+1)); } +if ! grep -qx -- "--expected-pivot-hash" "$ARGFILE" || ! grep -qx -- "deadbeef" "$ARGFILE"; then + echo "FAIL: should forward --expected-pivot-hash deadbeef"; fails=$((fails+1)); +fi + +echo "---"; [ "$fails" -eq 0 ] && echo "ALL PASS" || { echo "$fails FAILED"; exit 1; } +``` + +`chmod +x scripts/test_smoke_args.sh`. + +- [ ] **Step 2: Run to verify it fails** + +Run: `bash scripts/test_smoke_args.sh` +Expected: FAIL — smoke.sh doesn't yet parse `--canonical` (it errors "unknown argument") so the canonical assertions fail. + +- [ ] **Step 3: Implement the flags in `scripts/smoke.sh`** + +Add to the arg-parse loop (next to `--turnkey-client-version`): + +```bash + --canonical) CANONICAL=1; shift ;; + --expected-pivot-hash) + [ "$#" -ge 2 ] || { echo "--expected-pivot-hash requires a value" >&2; exit 2; } + EXPECTED_PIVOT_HASH="$2"; shift 2 ;; + --expected-pivot-hash=*) EXPECTED_PIVOT_HASH="${1#*=}"; shift ;; +``` + +Initialize defaults near the other vars: + +```bash +CANONICAL="${VSP_SMOKE_CANONICAL:-0}" +EXPECTED_PIVOT_HASH="${VSP_SMOKE_EXPECTED_PIVOT_HASH:-}" +``` + +Build the verify args before the run: + +```bash +verify_args=(verify --host "$HOST" --organization-id "$ORG" --key-name "$KEY" \ + --unsigned-payload "$PAYLOAD" --chain CHAIN_SOLANA) +[ "$CANONICAL" -eq 1 ] || verify_args+=(--dev-path) +[ -n "$EXPECTED_PIVOT_HASH" ] && verify_args+=(--expected-pivot-hash "$EXPECTED_PIVOT_HASH") +``` + +Replace the existing fixed invocation with: + +```bash +OUT="$($CLIENT "${verify_args[@]}" 2>"$ERRFILE")" +``` + +Update the header usage block to document `--canonical` and `--expected-pivot-hash`. + +- [ ] **Step 4: Run the new test + the existing harnesses** + +Run: +```bash +bash scripts/test_smoke_args.sh +shellcheck scripts/smoke.sh scripts/test_smoke_args.sh +``` +Expected: `ALL PASS`; shellcheck clean. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/smoke.sh scripts/test_smoke_args.sh +git commit -m "feat: smoke.sh --canonical + --expected-pivot-hash (pin served binary) + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Phase 4 — workflows + +Repo: `visualsign-parser`, `.github/workflows/`. Validate each with `python3 -c "import yaml;yaml.safe_load(open(''))"`. + +### Task 7: Rename dev workflow to "TVC Deploy (Dev)" + +**Files:** +- Rename: `.github/workflows/tvc-deploy.yml` -> `.github/workflows/tvc-deploy-dev.yml` +- Modify: the `name:` field. + +- [ ] **Step 1: Rename + retitle** + +```bash +git mv .github/workflows/tvc-deploy.yml .github/workflows/tvc-deploy-dev.yml +``` +Change line 1 `name: TVC Deploy` to `name: TVC Deploy (Dev)`. + +- [ ] **Step 2: Validate YAML** + +Run: `python3 -c "import yaml;yaml.safe_load(open('.github/workflows/tvc-deploy-dev.yml'));print('ok')"` +Expected: `ok`. + +- [ ] **Step 3: Commit** + +```bash +git add -A .github/workflows/ +git commit -m "ci(tools): rename TVC Deploy -> TVC Deploy (Dev) + +Co-Authored-By: Claude Opus 4.8 " +``` + +### Task 8: Release workflow (prod, initiate-only) + +**Files:** Create `.github/workflows/release.yml` + +- [ ] **Step 1: Create the workflow** + +```yaml +name: Release +# Manually-triggered PROD deploy INITIATE: digest gate + tvc deploy create. +# Holds only the Turnkey API key; a human operator approves out-of-band. +on: + workflow_dispatch: + inputs: + app_id: { description: "Prod TVC app id", required: true } + image_url: { description: "Pinned parser_app image, ghcr.io/...@sha256:...", required: true } + expected_digest: { description: "Expected pivot binary sha256 (hex)", required: true } + qos_version: { description: "QOS version", required: false, default: "v2026.2.6" } + host_ip: { description: "parser_app listen IP", required: false, default: "0.0.0.0" } + host_port: { description: "parser_app listen port", required: false, default: "3000" } +concurrency: + group: release-${{ inputs.app_id }} + cancel-in-progress: false +permissions: + contents: read +jobs: + initiate: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@fb51252c7ba57d633bc668f941da052e410add48 # v1.13.0 + - name: Install tvc CLI + run: cargo install tvc --version 0.7.0 --locked + - name: Build deploy helper + run: cargo build --release --manifest-path tools/tvc-deploy/Cargo.toml + - name: Initiate + env: + TVC_ORG_ID: ${{ secrets.TVC_PROD_ORG_ID }} + TVC_API_KEY_PUBLIC: ${{ secrets.TVC_PROD_API_KEY_PUBLIC }} + TVC_API_KEY_PRIVATE: ${{ secrets.TVC_PROD_API_KEY_PRIVATE }} + APP_ID: ${{ inputs.app_id }} + IMAGE_URL: ${{ inputs.image_url }} + EXPECTED_DIGEST: ${{ inputs.expected_digest }} + QOS_VERSION: ${{ inputs.qos_version }} + HOST_IP: ${{ inputs.host_ip }} + HOST_PORT: ${{ inputs.host_port }} + run: | + ./tools/tvc-deploy/target/release/tvc-deploy initiate \ + --app-id "$APP_ID" --image-url "$IMAGE_URL" \ + --expected-digest "$EXPECTED_DIGEST" --qos-version "$QOS_VERSION" \ + --host-ip "$HOST_IP" --host-port "$HOST_PORT" | tee "$RUNNER_TEMP/initiate.out" + echo "### Release initiated" >> "$GITHUB_STEP_SUMMARY" + grep "created deployment" "$RUNNER_TEMP/initiate.out" >> "$GITHUB_STEP_SUMMARY" || true + echo "Operator: approve with \`tvc-deploy approve --deploy-id --operator-id --image-url \"$IMAGE_URL\" --expected-digest \"$EXPECTED_DIGEST\"\` then run Promote." >> "$GITHUB_STEP_SUMMARY" +``` + +- [ ] **Step 2: Validate YAML + commit** + +```bash +python3 -c "import yaml;yaml.safe_load(open('.github/workflows/release.yml'));print('ok')" +git add .github/workflows/release.yml +git commit -m "ci(tools): Release workflow (prod initiate-only) + +Co-Authored-By: Claude Opus 4.8 " +``` + +### Task 9: Promote workflow (prod, set-live + pinned smoke) + +**Files:** Create `.github/workflows/promote.yml` + +- [ ] **Step 1: Create the workflow** + +```yaml +name: Promote +# Manually-triggered PROD set-live for an already-approved deployment, then a +# pinned smoke against the canonical /visualsign endpoint. API key only. +on: + workflow_dispatch: + inputs: + app_id: { description: "Prod TVC app id", required: true } + deploy_id: { description: "Approved deployment id from the Release run", required: true } + expected_digest: { description: "Pivot binary sha256 deployed (pins the smoke)", required: true } + turnkey_client_version: { description: "turnkey-client image tag", required: false, default: "latest" } +concurrency: + group: promote-${{ inputs.app_id }} + cancel-in-progress: false +permissions: + contents: read + packages: read +jobs: + promote: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Install Rust + uses: actions-rust-lang/setup-rust-toolchain@fb51252c7ba57d633bc668f941da052e410add48 # v1.13.0 + - name: Install tvc CLI + run: cargo install tvc --version 0.7.0 --locked + - name: Build deploy helper + run: cargo build --release --manifest-path tools/tvc-deploy/Cargo.toml + - name: Promote (set-live) + env: + TVC_ORG_ID: ${{ secrets.TVC_PROD_ORG_ID }} + TVC_API_KEY_PUBLIC: ${{ secrets.TVC_PROD_API_KEY_PUBLIC }} + TVC_API_KEY_PRIVATE: ${{ secrets.TVC_PROD_API_KEY_PRIVATE }} + APP_ID: ${{ inputs.app_id }} + DEPLOY_ID: ${{ inputs.deploy_id }} + run: | + ./tools/tvc-deploy/target/release/tvc-deploy promote \ + --app-id "$APP_ID" --deploy-id "$DEPLOY_ID" + - name: Smoke (canonical, pinned) + if: success() + env: + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TVC_API_KEY_PUBLIC: ${{ secrets.TVC_PROD_API_KEY_PUBLIC }} + TVC_API_KEY_PRIVATE: ${{ secrets.TVC_PROD_API_KEY_PRIVATE }} + VSP_SMOKE_ORG: ${{ secrets.TVC_PROD_ORG_ID }} + VSP_SMOKE_CLIENT_VERSION: ${{ inputs.turnkey_client_version || vars.TVC_SMOKE_CLIENT_VERSION || 'latest' }} + EXPECTED_DIGEST: ${{ inputs.expected_digest }} + run: | + echo "$GHCR_TOKEN" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin + umask 077 + mkdir -p "$HOME/.config/turnkey/keys" + printf '%s' "$TVC_API_KEY_PUBLIC" > "$HOME/.config/turnkey/keys/dev.public" + printf '%s:p256' "$TVC_API_KEY_PRIVATE" > "$HOME/.config/turnkey/keys/dev.private" + ./scripts/smoke.sh --canonical --expected-pivot-hash "$EXPECTED_DIGEST" + - name: Scrub secrets + if: always() + run: rm -rf "$HOME/.config/turnkey/keys" +``` + +- [ ] **Step 2: Validate YAML + commit** + +```bash +python3 -c "import yaml;yaml.safe_load(open('.github/workflows/promote.yml'));print('ok')" +git add .github/workflows/promote.yml +git commit -m "ci(tools): Promote workflow (prod set-live + pinned smoke) + +Co-Authored-By: Claude Opus 4.8 " +``` + +### Task 10: Runbook — prod procedure + +**Files:** Notion runbook (sub-page `38e5b28f-3091-81ba-b1f6-e2d6e641ddbb`). Not in-repo. + +- [ ] **Step 1:** Append a "Prod release (initiate -> approve -> promote)" section: trigger Release (record the deploy id), operator approves with `tvc-deploy approve` using the **prod** operator seed (1Password item TBD), trigger Promote with the deploy id + expected_digest, confirm the pinned smoke PASS. Note CI never holds the operator seed. +- [ ] **Step 2:** No commit (Notion). Mention in the PR description that the runbook was updated. + +--- + +## Self-Review + +**Spec coverage:** initiate/approve/promote subcommands -> Tasks 2-5; dev `deploy` composition -> Task 2; `verify --expected-pivot-hash` + surfaced pivot hash -> Task 1; smoke canonical + pin -> Task 6; rename dev workflow -> Task 7; Release -> Task 8; Promote + canonical pinned smoke -> Task 9; separation of duties -> Tasks 8/9 (API-key-only env, no seed); runbook -> Task 10. All spec sections covered. + +**Placeholder scan:** No TBD/TODO in steps; the prod org/app/operator ids are intentionally `secrets.TVC_PROD_*`/inputs per the Global Constraints (real values supplied at run time), and the 1Password prod item is an explicit open prerequisite from the spec, not a code placeholder. + +**Type consistency:** `TvcOps` method names (`verify_image_digest`, `create`, `approve`, `poll_health`, `set_live`) are identical across Tasks 2-5 and the `RecordingTvc` fake. `initiate` returns `Result`, consumed by `do_deploy`. `build_deploy_config` signature matches its test and `initiate` caller. Go `CheckExpectedPivotHash(*manifest.Manifest, string) error` matches its test and the `service.go` call site. diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 00000000..9bf8e23c --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# Post-deploy smoke test for the live dev VisualSign parser. +# +# Runs a known Solana V0 transaction (referencing address lookup tables) through +# the deployed parser (the /visualsign-dev endpoint) via `turnkey-client verify` +# and asserts BOTH: +# - it RENDERS — a regression guard for the "Cannot render V0 ... refusing to +# display a partial transaction" failure; and +# - it VERIFIES — the AWS Nitro attestation and the enclave signature are +# cryptographically valid (proof the parse ran inside the enclave). +# +# Drives the published turnkey-client CONTAINER (no Go toolchain needed); its +# JSON goes to stdout (asserted via `jq`) and its step-by-step verification log +# goes to stderr, which this script passes through by default so you can SEE the +# client ran and what it verified. +# +# When the container image is unavailable (e.g. not yet published), point +# --turnkey-client-path at a local fallback: an executable client binary, or a +# turnkey-client source dir that is built (`make build`) and run from +# bin/visualsign-turnkeyclient. +# +# Usage: smoke.sh [--turnkey-client-path ] +# [--turnkey-client-version ] [--quiet] +# +# Flags: +# --turnkey-client-path P local client used only when the container image +# is unavailable (or VSP_SMOKE_TURNKEY_CLIENT_PATH) +# --turnkey-client-version T container image tag to pull (default: latest); +# pin an approved version in CI (or via +# VSP_SMOKE_CLIENT_VERSION) +# --quiet, -q suppress the client's output on success (failures +# stay verbose); default shows it +# +# Env (all optional; defaults target the dev endpoint/app): +# VSP_SMOKE_HOST API host (default https://api.turnkey.com) +# VSP_SMOKE_ORG organization id +# VSP_SMOKE_KEY key name under ~/.config/turnkey/keys/.{public,private} +# VSP_SMOKE_CLIENT_VERSION container image tag (same as --turnkey-client-version) +# TURNKEY_CLIENT how to invoke the client (overrides all resolution) +# VSP_SMOKE_TURNKEY_CLIENT_PATH local fallback path (same as --turnkey-client-path) +# +# Exit: 0 = rendered + verified (pass) OR endpoint unreachable (skip; not ours); +# 1 = endpoint up but parser failed to render / verify / assertions failed; +# 2 = smoke could not run the client (e.g. missing/unpullable image or +# binary) — a harness failure, never treated as a pass. +set -euo pipefail + +HOST="${VSP_SMOKE_HOST:-https://api.turnkey.com}" +ORG="${VSP_SMOKE_ORG:-d7f51c3d-fb9d-47c1-9b2e-a02b1cd5ff14}" +KEY="${VSP_SMOKE_KEY:-dev}" +CLIENT_PATH="${VSP_SMOKE_TURNKEY_CLIENT_PATH:-}" +CLIENT_VERSION="${VSP_SMOKE_CLIENT_VERSION:-latest}" +QUIET=0 +while [ "$#" -gt 0 ]; do + case "$1" in + --turnkey-client-path) + [ "$#" -ge 2 ] || { echo "--turnkey-client-path requires a value" >&2; exit 2; } + CLIENT_PATH="$2"; shift 2 ;; + --turnkey-client-path=*) CLIENT_PATH="${1#*=}"; shift ;; + --turnkey-client-version) + [ "$#" -ge 2 ] || { echo "--turnkey-client-version requires a value" >&2; exit 2; } + CLIENT_VERSION="$2"; shift 2 ;; + --turnkey-client-version=*) CLIENT_VERSION="${1#*=}"; shift ;; + -q | --quiet) QUIET=1; shift ;; + -h | --help) + echo "usage: smoke.sh [--turnkey-client-path ] [--turnkey-client-version ] [--quiet]" >&2 + exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +IMAGE="ghcr.io/anchorageoss/visualsign-turnkeyclient:${CLIENT_VERSION}" +CONTAINER_CLIENT="docker run --rm -v $HOME/.config/turnkey/keys:/root/.config/turnkey/keys:ro $IMAGE" + +# Resolve a local fallback path to a runnable client: an executable is used +# directly; a directory is treated as the turnkey-client source and built +# (unless its binary already exists). Prints the client path on stdout. +resolve_fallback_client() { + local p="$1" + if [ -x "$p" ] && [ ! -d "$p" ]; then + printf '%s' "$p" + elif [ -d "$p" ]; then + local bin="$p/bin/visualsign-turnkeyclient" + if [ ! -x "$bin" ]; then + echo "building turnkey-client in $p ..." >&2 + ( cd "$p" && GOPATH="${GOPATH:-$HOME/go}" make build >&2 ) \ + || { echo "ERROR: failed to build turnkey-client in $p" >&2; exit 2; } + fi + [ -x "$bin" ] || { echo "ERROR: no client binary at $bin after build" >&2; exit 2; } + printf '%s' "$bin" + else + echo "ERROR: fallback client path is neither an executable nor a directory: $p" >&2 + exit 2 + fi +} + +# Client resolution: explicit override -> published container (if pullable) -> +# local fallback. With none available, keep the container command so the run's +# guard reports the missing image as a harness error (exit 2), not a pass. +if [ -n "${TURNKEY_CLIENT:-}" ]; then + CLIENT="$TURNKEY_CLIENT" +elif docker image inspect "$IMAGE" >/dev/null 2>&1 || docker pull "$IMAGE" >/dev/null 2>&1; then + CLIENT="$CONTAINER_CLIENT" +elif [ -n "$CLIENT_PATH" ]; then + echo "container image $IMAGE unavailable; using local fallback client: $CLIENT_PATH" >&2 + CLIENT="$(resolve_fallback_client "$CLIENT_PATH")" || exit $? +else + CLIENT="$CONTAINER_CLIENT" +fi + +DIR="$(cd "$(dirname "$0")/.." && pwd)" +PAYLOAD="$(tr -d '[:space:]' < "$DIR/testdata/solana_v0_alt.b64")" +ERRFILE="$(mktemp)" +trap 'rm -f "$ERRFILE"' EXIT + +set +e +OUT="$($CLIENT verify --dev-path --host "$HOST" --organization-id "$ORG" \ + --key-name "$KEY" --unsigned-payload "$PAYLOAD" --chain CHAIN_SOLANA 2>"$ERRFILE")" +RC=$? +set -e + +if [ "$RC" -ne 0 ]; then + # Show the client's own output so the failure is diagnosable (always, even + # under --quiet), then classify. Default to a hard error: only a recognized + # endpoint outage may skip, so a broken harness can't masquerade as a pass. + cat "$ERRFILE" >&2 + + # Endpoint reachable but the parser returned a non-OK status -> our regression. + if grep -q "non-OK status" "$ERRFILE"; then + echo "FAIL: deployed parser rejected a tx it should render (regression)" >&2 + exit 1 + fi + # A genuine transport/network error reaching the endpoint is a pre-existing + # outage, not the deploy's fault -> skip. Match only connection-level errors. + if grep -qiE \ + 'connection refused|connection reset|no such host|dial tcp|i/o timeout|timeout|tls handshake|\bEOF\b|context deadline|network is unreachable|server misbehaving|temporary failure in name resolution' \ + "$ERRFILE"; then + echo "SKIP: dev endpoint unreachable / outage — not a regression" >&2 + exit 0 + fi + # Anything else means the smoke harness itself could not run the client + # (missing/unpullable image, missing binary, bad invocation). NOT a pass: + # surface it loudly so a broken smoke can't be mistaken for success. + echo "ERROR: smoke could not run the turnkey-client; this is not an endpoint outage" >&2 + exit 2 +fi + +# Client ran. Pass its verification log through unless the caller asked to be +# quiet, so a PASS is visibly backed by the real step-by-step output. +[ "$QUIET" -eq 1 ] || cat "$ERRFILE" >&2 + +# Assert BOTH the render guard and the cryptographic verification result. +if ! echo "$OUT" | jq -e ' + (.signablePayload | length > 0) + and (.signablePayload | contains("Cannot render V0") | not) + and (.valid == true) + and (.attestationValid == true) + and (.signatureValid == true) +' >/dev/null; then + echo "FAIL: render/verification assertions failed. Response summary:" >&2 + echo "$OUT" | jq '{ + signablePayloadLen: (.signablePayload | length?), + valid, attestationValid, signatureValid, moduleId + }' >&2 || true + exit 1 +fi + +chars="$(printf '%s' "$OUT" | jq -r '.signablePayload | length')" +module="$(printf '%s' "$OUT" | jq -r '.moduleId // "unknown"')" +echo "PASS: turnkey-client verify succeeded; V0+ALT rendered ($chars chars, no \"Cannot render V0\"); attestation + signature cryptographically verified (executed in AWS Nitro enclave); moduleId=$module" diff --git a/testdata/solana_v0_alt.b64 b/testdata/solana_v0_alt.b64 new file mode 100644 index 00000000..4d44d2c6 --- /dev/null +++ b/testdata/solana_v0_alt.b64 @@ -0,0 +1 @@ +AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQADCZtM4h7EJt+rL74nYQlqxZ8bzSZzUp2BrAxa7kc1FFTkAtD21dzZ16If9Nj++sNi1X037Iu4+2L4YQ/+0bkjbqQV8gv6awyCDA09nhYGX4LxUkkVRTmTnBDcg3cIFgdrxSvUzEmQZ01Rcls8+lIB6m2hLNxDLv3jwDCSppQNwB6rUXlzKJ0reoxS1Zqy/hN6436ivtTDXtln20wNP+/tIHKGz2h6Dektn5XGJZgSTWIC0aOK+Y/Gnkp92Lf/lrW0yAMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAru1ER3Jgl5GnIq+Tzy+liYUs3Ramx2kpN9tstanaf+K140oU4rxzSGkO4fWvXe7WVThAo22quGCwUGBzvcA8EKaQdYeOzygtDzoWVHgZmrbXl53fj2TzEWXCwkjT65h0BAYABQJZOwMABgAJA2QAAAAAAAAACCcVGhAAGAgIExYICBkADw4NAwIUHBcHHQwBCgsCBRcSCREAFRUbEQSZAfjGnpHhdYfIBAAAACUXJ+akMISTHvdf2u2yPj1y5Z8LFm2HEJ+MNqCpUNyWK3JJKr+JVhKDPln3J+uMrzrJZHmalr2S+tTKBwEHJgwHpdmFGQAAoKaiAAAAMQIAAAAABgAAAAAAAAZ+/l2azAAAAAgXQEtMAAAAAABsK5OBHTcSpIEEPx4AAAAAAAABgQOfFgQAAAAAFAAAAAgDAAUVCGMoDmkta6zJA+en42qS4Htcs16V37AwUQTsvWDiSrp+EPVNc8wpAVHCAA4AGYYCBAYHowwiCDaHP/CW9DnU//osBUi9TtvJ8Q7lmuEbSbLLo6rBrWQG41nkBBYXHRwAhNvdrJBlD8tDbz2t3t7SoB3BYNSCWufSFKzshP9ZbAkDT01QAA== diff --git a/tools/tvc-deploy/.gitignore b/tools/tvc-deploy/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/tools/tvc-deploy/.gitignore @@ -0,0 +1 @@ +/target diff --git a/tools/tvc-deploy/Cargo.lock b/tools/tvc-deploy/Cargo.lock new file mode 100644 index 00000000..dedd1ab6 --- /dev/null +++ b/tools/tvc-deploy/Cargo.lock @@ -0,0 +1,697 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8fb4fb5740e4b2c4884ff95f5f32f5e8479db1e8fd8eb49ddbe09eb09bb7c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lexopt" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803ec87c9cfb29b9d2633f20cba1f488db3fd53f2158b1024cbefb47ba05d413" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "qos_hex" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a19810c8e0561e92b11bf4fbf1ae880a7251826b8aa711533c8e773c5e7ca1" + +[[package]] +name = "qos_p256" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d9af0113f0e9996a4f9d4446cbed70cd1682119d87536572f425fe752f2205e" +dependencies = [ + "aes-gcm", + "borsh", + "hkdf", + "hmac", + "p256", + "qos_hex", + "sha2", + "zeroize", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tvc-deploy" +version = "0.1.0" +dependencies = [ + "anyhow", + "lexopt", + "qos_p256", + "serde_json", + "xshell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "xshell" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e7290c623014758632efe00737145b6867b66292c42167f2ec381eb566a373d" +dependencies = [ + "xshell-macros", +] + +[[package]] +name = "xshell-macros" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32ac00cd3f8ec9c1d33fb3e7958a82df6989c42d747bd326c822b1d625283547" + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tools/tvc-deploy/Cargo.toml b/tools/tvc-deploy/Cargo.toml new file mode 100644 index 00000000..d0487462 --- /dev/null +++ b/tools/tvc-deploy/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "tvc-deploy" +version = "0.1.0" +edition = "2021" +description = "Standalone TVC deploy helper for parser_app (operator-key minting + deploy orchestration)" + +[[bin]] +name = "tvc-deploy" +path = "src/main.rs" + +[dependencies] +anyhow = "1.0.102" +lexopt = "0.3.2" +qos_p256 = { version = "0.6.1", default-features = false } +serde_json = "1" +xshell = "0.2.7" + +# Own workspace root so this stays independent of the parser cargo workspace +# under ../../src and compiles fast on its own. +[workspace] diff --git a/tools/tvc-deploy/src/main.rs b/tools/tvc-deploy/src/main.rs new file mode 100644 index 00000000..65c3497c --- /dev/null +++ b/tools/tvc-deploy/src/main.rs @@ -0,0 +1,709 @@ +//! Standalone TVC deploy helper for `parser_app`. +//! +//! Subcommands: +//! gen-operator-key --out +//! Mint a qos_p256 operator key. Writes the 32-byte master seed (hex) to +//! with mode 0600 and prints ONLY the public key hex to stdout. +//! initiate --app-id --image-url --expected-digest +//! [--qos-version v] [--host-ip 0.0.0.0] [--host-port 3000] +//! Run the image-digest gate and create the deployment, printing the +//! deployment ID. Does not approve or set-live (prod initiate step). +//! deploy --app-id --image-url --expected-digest +//! --operator-id [--operator-seed ] [--qos-version v] +//! [--host-ip 0.0.0.0] [--host-port 3000] +//! Re-derive the pivot binary digest from the image and assert it matches +//! --expected-digest, then assemble tvc-deploy.json (gRPC health), create +//! the deployment, approve, poll until healthy, and set it live. +//! The operator seed resolves flag -> env TVC_CI_OPERATOR_SEED -> none; when +//! none is given, approval uses the logged-in org operator key (`tvc login`). +//! approve --deploy-id --operator-id --image-url +//! --expected-digest [--operator-seed ] +//! Re-run the digest gate, then approve the manifest (operator step). +//! promote --app-id --deploy-id +//! Poll the deployment to healthy, then set it live (prod promote step). +//! +//! All Turnkey API actions shell out to the `tvc` CLI (it owns auth/consensus); +//! this binary owns config assembly, the image-digest safety gate, and polling. + +use std::collections::HashMap; +use std::ffi::OsString; +use std::fs::{OpenOptions, Permissions}; +use std::io::Write; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::thread::sleep; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, bail, Context, Result}; +use qos_p256::P256Pair; +use xshell::{cmd, Shell}; + +const POLL_TIMEOUT: Duration = Duration::from_secs(900); +const POLL_INTERVAL: Duration = Duration::from_secs(15); +const SETLIVE_TIMEOUT: Duration = Duration::from_secs(300); + +const USAGE: &str = "usage:\n \ + tvc-deploy gen-operator-key --out \n \ + tvc-deploy initiate --app-id --image-url --expected-digest \ + [--qos-version v2026.2.6] [--host-ip 0.0.0.0] [--host-port 3000]\n \ + tvc-deploy deploy --app-id --image-url --expected-digest --operator-id \ + [--operator-seed ] [--qos-version v2026.2.6] [--host-ip 0.0.0.0] [--host-port 3000]\n \ + tvc-deploy approve --deploy-id --operator-id --image-url --expected-digest \ + [--operator-seed ]\n \ + tvc-deploy promote --app-id --deploy-id \n \ + (operator seed may instead come from env TVC_CI_OPERATOR_SEED, or be omitted \ + to approve with the logged-in org operator key)"; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {e:#}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<()> { + let (subcmd, flags) = parse_args()?; + let sh = Shell::new()?; + match subcmd.as_str() { + "gen-operator-key" => gen_operator_key(&flags), + "initiate" => initiate(&RealTvc { sh: &sh }, &flags).map(|_| ()), + "deploy" => do_deploy(&RealTvc { sh: &sh }, &flags), + "approve" => approve(&RealTvc { sh: &sh }, &flags), + "promote" => promote(&RealTvc { sh: &sh }, &flags), + other => bail!("unknown subcommand {other:?}\n{USAGE}"), + } +} + +/// Parse ` --key value ...` into the subcommand and a flag map. +fn parse_args() -> Result<(String, HashMap)> { + use lexopt::prelude::*; + let mut parser = lexopt::Parser::from_env(); + let mut subcmd: Option = None; + let mut flags = HashMap::new(); + while let Some(arg) = parser.next()? { + match arg { + Value(v) if subcmd.is_none() => subcmd = Some(v.string()?), + Long(name) => { + let name = name.to_owned(); + let val = parser + .value() + .with_context(|| format!("--{name} requires a value"))? + .string()?; + flags.insert(name, val); + } + other => return Err(other.unexpected().into()), + } + } + let subcmd = subcmd.ok_or_else(|| anyhow!("missing subcommand\n{USAGE}"))?; + Ok((subcmd, flags)) +} + +fn req<'a>(flags: &'a HashMap, key: &str) -> Result<&'a String> { + flags.get(key).with_context(|| format!("missing --{key}")) +} + +/// Trait abstracting all external TVC/Docker operations for testability. +trait TvcOps { + fn verify_image_digest(&self, image: &str, expected: &str) -> Result<()>; + fn create(&self, cfg_path: &Path) -> Result; + fn approve(&self, deploy_id: &str, operator_id: &str, seed: Option<&Path>) -> Result<()>; + fn poll_health(&self, app_id: &str, deploy_id: &str, timeout: Duration) -> Result<()>; + fn set_live(&self, deploy_id: &str, timeout: Duration) -> Result<()>; +} + +struct RealTvc<'a> { + sh: &'a Shell, +} + +impl TvcOps for RealTvc<'_> { + fn verify_image_digest(&self, image: &str, expected: &str) -> Result<()> { + verify_image_digest(self.sh, image, expected) + } + fn create(&self, cfg_path: &Path) -> Result { + let created = cmd!(self.sh, "tvc deploy create --config-file {cfg_path}") + .read() + .context("tvc deploy create")?; + parse_after(&created, "Deployment ID:") + .with_context(|| format!("no deployment id in create output:\n{created}")) + } + fn approve(&self, deploy_id: &str, operator_id: &str, seed: Option<&Path>) -> Result<()> { + let mut seed_args: Vec = Vec::new(); + if let Some(p) = seed { + seed_args.push("--operator-seed".into()); + seed_args.push(p.into()); + } + cmd!(self.sh, "tvc deploy approve --deploy-id {deploy_id} --operator-id {operator_id} {seed_args...} --dangerous-skip-interactive") + .run() + .context("tvc deploy approve") + } + fn poll_health(&self, app_id: &str, deploy_id: &str, timeout: Duration) -> Result<()> { + poll_health(self.sh, app_id, deploy_id, timeout) + } + fn set_live(&self, deploy_id: &str, timeout: Duration) -> Result<()> { + set_live(self.sh, deploy_id, timeout) + } +} + +fn gen_operator_key(flags: &HashMap) -> Result<()> { + let out = req(flags, "out")?; + let pair = P256Pair::generate().map_err(|e| anyhow!("key generation failed: {e:?}"))?; + // qos_p256 owns the master-seed / pubkey hex formats. + let seed_hex = String::from_utf8(pair.to_master_seed_hex()).context("seed hex not utf8")?; + let pub_hex = + String::from_utf8(pair.public_key().to_hex_bytes()).context("pubkey hex not utf8")?; + write_secret_file(Path::new(out), &seed_hex)?; + // SECURITY: only the public key is ever printed; the seed stays in the file. + println!("{pub_hex}"); + eprintln!("operator seed written to {out} (mode 0600); public key printed above"); + Ok(()) +} + +fn write_secret_file(path: &Path, contents: &str) -> Result<()> { + let mut f = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path) + .with_context(|| format!("open {}", path.display()))?; + // mode() only applies when the file is newly created; force 0600 in case it + // pre-existed with broader perms, so the secret is never world-readable. + f.set_permissions(Permissions::from_mode(0o600)) + .with_context(|| format!("chmod {}", path.display()))?; + f.write_all(contents.as_bytes()) + .with_context(|| format!("write {}", path.display())) +} + +fn build_deploy_config( + app_id: &str, + qos: &str, + image: &str, + host_ip: &str, + host_port: u16, + digest: &str, +) -> serde_json::Value { + serde_json::json!({ + "appId": app_id, + "qosVersion": qos, + "pivotContainerImageUrl": image, + "pivotPath": "/parser_app", + "pivotArgs": ["--host-ip", host_ip, "--host-port", host_port.to_string()], + "expectedPivotDigest": digest, + "debugMode": false, + "healthCheckType": "TVC_HEALTH_CHECK_TYPE_GRPC", + "healthCheckPort": host_port, + "publicIngressPort": host_port, + }) +} + +fn initiate(ops: &impl TvcOps, flags: &HashMap) -> Result { + let app_id = req(flags, "app-id")?; + let image = req(flags, "image-url")?; + let digest = req(flags, "expected-digest")?; + let qos = flags + .get("qos-version") + .map(String::as_str) + .unwrap_or("v2026.2.6"); + let host_ip = flags + .get("host-ip") + .map(String::as_str) + .unwrap_or("0.0.0.0"); + let host_port: u16 = match flags.get("host-port") { + Some(s) => s + .parse() + .with_context(|| format!("invalid --host-port: {s}"))?, + None => 3000, + }; + validate_digest(digest)?; + ops.verify_image_digest(image, digest)?; + let cfg = build_deploy_config(app_id, qos, image, host_ip, host_port, digest); + let cfg_path = temp_path("tvc-deploy", "json"); + std::fs::write(&cfg_path, serde_json::to_vec_pretty(&cfg)?) + .with_context(|| format!("write {}", cfg_path.display()))?; + let deploy_id = ops.create(&cfg_path); + let _ = std::fs::remove_file(&cfg_path); + let deploy_id = deploy_id?; + println!("created deployment {deploy_id}"); + Ok(deploy_id) +} + +/// Approve `deploy_id` as `operator_id` with the resolved seed, then ALWAYS +/// remove an env-sourced seed temp file (cleanup=true) before propagating, so +/// the operator seed never leaks on an approve failure. +fn approve_and_cleanup( + ops: &impl TvcOps, + deploy_id: &str, + operator_id: &str, + seed: &Option<(PathBuf, bool)>, +) -> Result<()> { + let result = ops.approve( + deploy_id, + operator_id, + seed.as_ref().map(|(p, _)| p.as_path()), + ); + if let Some((path, true)) = seed { + let _ = std::fs::remove_file(path); + } + result +} + +fn do_deploy(ops: &impl TvcOps, flags: &HashMap) -> Result<()> { + let app_id = req(flags, "app-id")?; + let operator_id = req(flags, "operator-id")?; + let deploy_id = initiate(ops, flags)?; + let seed = resolve_seed_file(flags)?; + approve_and_cleanup(ops, &deploy_id, operator_id, &seed)?; + println!("approved manifest for {deploy_id}"); + ops.poll_health(app_id, &deploy_id, POLL_TIMEOUT)?; + ops.set_live(&deploy_id, SETLIVE_TIMEOUT)?; + println!("deployment {deploy_id} is healthy and live"); + Ok(()) +} + +fn approve(ops: &impl TvcOps, flags: &HashMap) -> Result<()> { + let deploy_id = req(flags, "deploy-id")?; + let operator_id = req(flags, "operator-id")?; + let image = req(flags, "image-url")?; + let digest = req(flags, "expected-digest")?; + validate_digest(digest)?; + ops.verify_image_digest(image, digest)?; + let seed = resolve_seed_file(flags)?; + approve_and_cleanup(ops, deploy_id, operator_id, &seed)?; + println!("approved manifest for {deploy_id}"); + Ok(()) +} + +fn promote(ops: &impl TvcOps, flags: &HashMap) -> Result<()> { + let app_id = req(flags, "app-id")?; + let deploy_id = req(flags, "deploy-id")?; + ops.poll_health(app_id, deploy_id, POLL_TIMEOUT)?; + ops.set_live(deploy_id, SETLIVE_TIMEOUT)?; + println!("deployment {deploy_id} is healthy and live"); + Ok(()) +} + +/// Extract `/parser_app` from the image and sha256 it; it MUST equal the +/// submitted `--expected-digest`. Ties the deployed digest to the real binary. +fn verify_image_digest(sh: &Shell, image: &str, expected: &str) -> Result<()> { + let cid = cmd!(sh, "docker create {image} /bin/true") + .read() + .context("docker create (digest gate)")?; + let cid = cid.trim().to_owned(); + let bin = temp_path("parser_app", "bin"); + let target = format!("{cid}:/parser_app"); + // Extract + hash the pivot binary, then ALWAYS clean up the container and the + // temp file regardless of where this fails (no leftover binary on error). + let hashed = (|| -> Result { + cmd!(sh, "docker cp {target} {bin}") + .run() + .context("docker cp /parser_app")?; + let sha = cmd!(sh, "sha256sum {bin}").read().context("sha256sum")?; + Ok(sha.split_whitespace().next().unwrap_or_default().to_owned()) + })(); + let _ = cmd!(sh, "docker rm {cid}").ignore_status().quiet().run(); + let _ = std::fs::remove_file(&bin); + let actual = hashed?; + if !actual.eq_ignore_ascii_case(expected) { + bail!( + "DIGEST GATE FAILED: image /parser_app sha256 {actual} != expected {expected}; refusing to deploy" + ); + } + println!("digest gate passed: image /parser_app sha256 == {expected}"); + Ok(()) +} + +/// Set the deployment live, retrying while TVC reports the status is still +/// settling. A fresh app auto-targets its first deploy on approval, surfacing as +/// an "already" error -- treat that as success. +fn set_live(sh: &Shell, deploy_id: &str, timeout: Duration) -> Result<()> { + let start = Instant::now(); + loop { + let out = cmd!(sh, "tvc app set-live-deploy --deploy-id {deploy_id}") + .ignore_status() + .output() + .context("tvc app set-live-deploy")?; + if out.status.success() { + println!("set {deploy_id} live"); + return Ok(()); + } + let msg = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ) + .to_lowercase(); + if msg.contains("already") { + println!("{deploy_id} already live (auto-targeted)"); + return Ok(()); + } + let transient = msg.contains("not yet available") + || msg.contains("try again") + || msg.contains("not found") + || msg.contains("zero healthy replicas"); + if transient && start.elapsed() < timeout { + sleep(POLL_INTERVAL); + continue; + } + bail!("set-live failed: {}", msg.trim()); + } +} + +fn poll_health(sh: &Shell, app_id: &str, deploy_id: &str, timeout: Duration) -> Result<()> { + let start = Instant::now(); + let mut last = String::new(); + loop { + // Status can fail transiently right after set-live while the app + // registers; keep polling through errors until timeout. + let status = cmd!(sh, "tvc app status --app-id {app_id}") + .ignore_status() + .quiet() + .read(); + if let Ok(out) = status { + if let Some(ratio) = deployment_health(&out, deploy_id) { + if ratio != last { + println!(" {deploy_id}: {ratio}"); + last = ratio.clone(); + } + if let Some((h, d)) = ratio.split_once('/') { + if h == d && h != "0" { + return Ok(()); + } + } + } + } + if start.elapsed() >= timeout { + bail!( + "timed out after {}s waiting for {deploy_id} to be healthy (last: {})", + timeout.as_secs(), + if last.is_empty() { "unknown" } else { &last } + ); + } + sleep(POLL_INTERVAL); + } +} + +/// From `tvc app status` output, the `Healthy / Desired Replicas: X/Y` ratio for +/// `deploy_id`'s block. +fn deployment_health(status: &str, deploy_id: &str) -> Option { + let mut in_block = false; + for line in status.lines() { + let t = line.trim(); + if let Some(rest) = t.strip_prefix("Deployment:") { + in_block = rest.trim() == deploy_id; + } else if in_block { + if let Some(rest) = t.strip_prefix("Healthy / Desired Replicas:") { + return rest.split_whitespace().next().map(str::to_owned); + } + } + } + None +} + +fn validate_digest(d: &str) -> Result<()> { + if d.len() == 64 && d.bytes().all(|b| b.is_ascii_hexdigit()) { + Ok(()) + } else { + bail!("--expected-digest must be 64 hex chars (sha256), got {d:?}"); + } +} + +/// Resolve the operator seed to a file path, returning `(path, cleanup)` or +/// `None`. Prefers `--operator-seed `; else reads the hex seed from env +/// `TVC_CI_OPERATOR_SEED` into a temp 0600 file (cleanup=true so the caller +/// deletes it); if neither is set, returns `None` and approval falls back to the +/// logged-in org operator key. +fn resolve_seed_file(flags: &HashMap) -> Result> { + if let Some(p) = flags.get("operator-seed") { + return Ok(Some((PathBuf::from(p), false))); + } + match std::env::var("TVC_CI_OPERATOR_SEED") { + Ok(seed) => { + let p = temp_path("tvc-operator", "seed"); + write_secret_file(&p, seed.trim())?; + Ok(Some((p, true))) + } + Err(_) => Ok(None), + } +} + +/// Trimmed remainder of the first line containing `marker`. +fn parse_after(haystack: &str, marker: &str) -> Option { + haystack.lines().find_map(|line| { + line.find(marker) + .map(|i| line[i + marker.len()..].trim().to_owned()) + .filter(|s| !s.is_empty()) + }) +} + +fn temp_path(prefix: &str, ext: &str) -> PathBuf { + // PID + timestamp + a per-process counter so repeated calls within one clock + // tick can't collide (the timestamp alone is coarse on some VMs). + static SEQ: AtomicU64 = AtomicU64::new(0); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + let seq = SEQ.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!( + "{prefix}-{}-{nanos}-{seq}.{ext}", + std::process::id() + )) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::sync::Mutex; + + static SEED_ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[derive(Default)] + struct RecordingTvc { + calls: RefCell>, + } + impl TvcOps for RecordingTvc { + fn verify_image_digest(&self, _image: &str, _expected: &str) -> Result<()> { + self.calls.borrow_mut().push("verify_image_digest".into()); + Ok(()) + } + fn create(&self, _cfg_path: &Path) -> Result { + self.calls.borrow_mut().push("create".into()); + Ok("deploy-123".into()) + } + fn approve(&self, deploy_id: &str, _operator_id: &str, _seed: Option<&Path>) -> Result<()> { + self.calls.borrow_mut().push(format!("approve:{deploy_id}")); + Ok(()) + } + fn poll_health(&self, _app: &str, deploy_id: &str, _t: Duration) -> Result<()> { + self.calls.borrow_mut().push(format!("poll:{deploy_id}")); + Ok(()) + } + fn set_live(&self, deploy_id: &str, _t: Duration) -> Result<()> { + self.calls + .borrow_mut() + .push(format!("set_live:{deploy_id}")); + Ok(()) + } + } + + fn flags(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect() + } + + #[test] + fn config_has_grpc_health_and_digest() { + let cfg = build_deploy_config( + "app", + "v1", + "img", + "0.0.0.0", + 3000, + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + ); + assert_eq!(cfg["healthCheckType"], "TVC_HEALTH_CHECK_TYPE_GRPC"); + assert_eq!( + cfg["expectedPivotDigest"], + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + ); + assert_eq!(cfg["pivotPath"], "/parser_app"); + assert_eq!(cfg["healthCheckPort"], 3000); + } + + #[test] + fn deploy_runs_gate_create_approve_poll_setlive_in_order() { + let ops = RecordingTvc::default(); + let f = flags(&[ + ("app-id", "app"), + ("image-url", "img"), + ( + "expected-digest", + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + ), + ("operator-id", "op"), + ("operator-seed", "/tmp/seed"), + ]); + do_deploy(&ops, &f).unwrap(); + assert_eq!( + *ops.calls.borrow(), + vec![ + "verify_image_digest", + "create", + "approve:deploy-123", + "poll:deploy-123", + "set_live:deploy-123", + ] + ); + } + + #[test] + fn initiate_runs_only_gate_and_create() { + let ops = RecordingTvc::default(); + let f = flags(&[ + ("app-id", "app"), + ("image-url", "img"), + ( + "expected-digest", + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + ), + ]); + let id = initiate(&ops, &f).unwrap(); + assert_eq!(id, "deploy-123"); + assert_eq!(*ops.calls.borrow(), vec!["verify_image_digest", "create"]); + } + + #[test] + fn initiate_rejects_bad_digest() { + let ops = RecordingTvc::default(); + let f = flags(&[ + ("app-id", "a"), + ("image-url", "i"), + ("expected-digest", "xyz"), + ]); + assert!(initiate(&ops, &f).is_err()); + assert!(ops.calls.borrow().is_empty()); + } + + fn leftover_operator_seeds() -> usize { + std::fs::read_dir(std::env::temp_dir()) + .map(|rd| { + rd.filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().starts_with("tvc-operator-")) + .count() + }) + .unwrap_or(0) + } + + #[test] + fn deploy_cleans_env_seed_when_approve_fails() { + let _env = SEED_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + struct FailingApprove; + impl TvcOps for FailingApprove { + fn verify_image_digest(&self, _i: &str, _e: &str) -> Result<()> { + Ok(()) + } + fn create(&self, _c: &Path) -> Result { + Ok("deploy-1".into()) + } + fn approve(&self, _d: &str, _o: &str, seed: Option<&Path>) -> Result<()> { + assert!( + seed.map(Path::exists).unwrap_or(false), + "seed must exist at approve" + ); + bail!("approve boom") + } + fn poll_health(&self, _a: &str, _d: &str, _t: Duration) -> Result<()> { + panic!("poll_health must not run after approve failure") + } + fn set_live(&self, _d: &str, _t: Duration) -> Result<()> { + panic!("set_live must not run after approve failure") + } + } + let digest = "a".repeat(64); + let f = flags(&[ + ("app-id", "app"), + ("image-url", "img"), + ("expected-digest", &digest), + ("operator-id", "op"), + ]); + let before = leftover_operator_seeds(); + // SAFETY: this is the only test that touches this env var. + unsafe { + std::env::set_var("TVC_CI_OPERATOR_SEED", "00".repeat(32)); + } + let result = do_deploy(&FailingApprove, &f); + unsafe { + std::env::remove_var("TVC_CI_OPERATOR_SEED"); + } + assert!(result.is_err(), "approve failure should propagate"); + assert_eq!( + before, + leftover_operator_seeds(), + "env-sourced seed leaked on approve failure" + ); + } + + #[test] + fn deploy_does_not_write_seed_when_initiate_fails() { + let _env = SEED_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + struct FailingGate; + impl TvcOps for FailingGate { + fn verify_image_digest(&self, _i: &str, _e: &str) -> Result<()> { + bail!("gate boom") + } + fn create(&self, _c: &Path) -> Result { + panic!("create must not run when the gate fails") + } + fn approve(&self, _d: &str, _o: &str, _s: Option<&Path>) -> Result<()> { + panic!("approve must not run") + } + fn poll_health(&self, _a: &str, _d: &str, _t: Duration) -> Result<()> { + panic!("poll_health must not run") + } + fn set_live(&self, _d: &str, _t: Duration) -> Result<()> { + panic!("set_live must not run") + } + } + let digest = "a".repeat(64); + let f = flags(&[ + ("app-id", "app"), + ("image-url", "img"), + ("expected-digest", &digest), + ("operator-id", "op"), + ]); + let before = leftover_operator_seeds(); + unsafe { + std::env::set_var("TVC_CI_OPERATOR_SEED", "00".repeat(32)); + } + let result = do_deploy(&FailingGate, &f); + unsafe { + std::env::remove_var("TVC_CI_OPERATOR_SEED"); + } + assert!(result.is_err(), "initiate failure should propagate"); + assert_eq!( + before, + leftover_operator_seeds(), + "seed must not be written when initiate fails" + ); + } + + #[test] + fn approve_reverifies_then_approves() { + let ops = RecordingTvc::default(); + let f = flags(&[ + ("deploy-id", "deploy-7"), + ("operator-id", "op"), + ("image-url", "img"), + ( + "expected-digest", + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + ), + ("operator-seed", "/tmp/seed"), + ]); + approve(&ops, &f).unwrap(); + assert_eq!( + *ops.calls.borrow(), + vec!["verify_image_digest", "approve:deploy-7"] + ); + } + + #[test] + fn promote_polls_then_sets_live() { + let ops = RecordingTvc::default(); + let f = flags(&[("app-id", "app"), ("deploy-id", "deploy-9")]); + promote(&ops, &f).unwrap(); + assert_eq!( + *ops.calls.borrow(), + vec!["poll:deploy-9", "set_live:deploy-9"] + ); + } +}