Skip to main content

cloud/
lib.rs

1//! @yah:relay(R040, "yah-cloud Phase 1: mirror bootstrap (Hetzner driver, Podman runtime, yah-yubaba)")
2//! @yah:status(review)
3//! @yah:parent(Q058)
4//! @yah:next("yah-A track (8 tickets) — substrate for noisetable cloud + future managed camps; gates noisetable C6 (E2E) and D1–D5 (corpus migration)")
5//! @yah:next("Adjacent relays: R019 (SSH-RPC remote camps), R032 (yah-agentd), R034 (identity registry — hostkeys overlap)")
6//! @yah:next("Reparented under Q058 cloud quest 2026-05-07. Largely superseded by R091 (yubaba orchestration + integration testing) and R092 (managed-camps CLI + .yah/cloud/ schema) — net new work should land on those relays. Remaining unique R040 scope (Cloudflare Tunnel cookbook F15, pg-on-mesh recipe F16, mesh-promote phasing F18-F19) stays here as historical/operational reference; consider archiving once R091+R092 reach review.")
7//! @yah:next("Refinement open questions: subcommand framework alignment with yah arch/board, where hostkey fingerprints live, reverse proxy choice. Crate-layout question soft-answered by this stub being a sibling crate; A1 may still refactor into a sub-module of an existing crate if desired (move the //! block + delete this stub).")
8//! @arch:see(architecture/PHASE_1_MIRROR_BOOTSTRAP.md)
9//! @arch:see(architecture/yah-managed-camps-topology.md)
10//!
11//! @yah:ticket(R040-F1, "A1: .yah/cloud/ schema + parser (MachineConfig, MirrorConfig, ServiceConfig)")
12//! @yah:at(2026-05-05T00:29:11Z)
13//! @yah:assignee(agent:claude)
14//! @yah:status(review)
15//! @yah:phase(P1)
16//! @yah:parent(R040)
17//! @yah:next("Per-file TOML layout: machines/<name>.toml, mirrors/<camp>.toml, services/<name>.toml — diffability + parallel editing")
18//! @yah:next("Sketch types in arch doc; refine to fit actual yah crate structure (this stub crate is one option; folding into an existing crate is the other)")
19//! @yah:verify("cargo test -p cloud config::tests round-trips all three config types")
20//! @arch:see(architecture/PHASE_1_MIRROR_BOOTSTRAP.md)
21//! @yah:handoff("Schema + parser complete: MachineConfig, MirrorConfig, ServiceConfig, BucketSpec, PortMapping in crates/yah/cloud/src/config.rs. Per-file TOML layout via CloudConfig::load. MachineConfig::save for write-back (A4 hostkey). 6 tests pass (round-trips + tempdir integration + save/reload). tempfile dev-dep added. No warnings.")
22//! @yah:next("A2: yah cloud subcommand stub — wire into yah CLI using same clap/subcommand pattern as yah board/arch. Commands: machine {status,provision,destroy}, mirror {show,status}, service {deploy,status}, agent {ping,services,logs}. All no-op except status/show which dump parsed .yah/cloud/ config. Verify: yah cloud mirror show noisetable prints declared regions/services.")
23//!
24//! @yah:ticket(R040-F3, "A3: Hetzner driver — MachineProvider trait + hcloud impl (server, bucket, status, destroy)")
25//! @yah:at(2026-05-05T00:29:11Z)
26//! @yah:assignee(agent:claude)
27//! @yah:status(review)
28//! @yah:phase(P1)
29//! @yah:parent(R040)
30//! @yah:next("API token from HETZNER_API_TOKEN env; document in .yah/cloud/SECRETS.md")
31//! @yah:next("Gotcha: Hetzner has TWO APIs (Cloud and Robot) — Phase 1 is Cloud only; Object Storage is S3-compat, separate surface")
32//! @yah:next("Verify before A6: Hetzner Hillsboro + Ashburn Object Storage both GA; CPX-22 available in PDX/IAD/FSN")
33//! @yah:verify("cargo test -p cloud provider::hetzner -- --ignored (gated; needs token)")
34//! @arch:see(architecture/PHASE_1_MIRROR_BOOTSTRAP.md)
35//! @yah:handoff("MachineProvider trait + HetznerDriver impl complete in crates/yah/cloud/src/provider/. Cloud API (create_server, server_status, destroy_server) uses HETZNER_API_TOKEN via reqwest+bearer. create_bucket uses Hetzner Object Storage S3-compat with AWS Sig V4 signing (HETZNER_S3_ACCESS_KEY + HETZNER_S3_SECRET_KEY). Location enum maps pdx/iad/fsn to hetzner cloud IDs and S3 endpoints. 9 unit tests pass; 2 integration tests behind #[ignore] gate. .yah/cloud/SECRETS.md created documenting all three token types.")
36//! @yah:next("A2: wire yah cloud subcommand stub into CLI (yah board/arch pattern). All commands no-op except machine status + mirror show which dump parsed .yah/cloud/ config. Verify: yah cloud mirror show noisetable prints declared regions/services.")
37//!
38//! @yah:ticket(R040-F4, "A4: cloud-init YAML + yah cloud machine provision (Debian + Podman + Tailscale + yah-yubaba)")
39//! @yah:at(2026-05-05T00:29:11Z)
40//! @yah:status(review)
41//! @yah:phase(P1)
42//! @yah:parent(R040)
43//! @yah:next("Template at .yah/cloud/cloud-init/mirror.yml; renderer substitutes YAH_WARDEN_BASE64 + HEADSCALE_PREAUTH_KEY + TAGS per machine")
44//! @yah:next("Hostkey generated ON the machine (ssh-keygen in cloud-init); private half never touches dev machines")
45//! @yah:next("Provision command polls cloud-init done, reads back hostkey fingerprint via yah-yubaba /identity, writes back to machine.toml")
46//! @yah:next("Surface /var/log/cloud-init.log and /var/log/cloud-init-output.log on failure")
47//! @yah:verify("yah cloud machine provision noisetable-pdx-1 --dry-run prints rendered cloud-init YAML")
48//! @arch:see(architecture/PHASE_1_MIRROR_BOOTSTRAP.md)
49//! @yah:handoff("Cloud-init template + renderer + provision command landed. Template at .yah/cloud/cloud-init/mirror.yml (canonical) with embedded copy at crates/yah/cloud/templates/mirror.yml (drift-test in cloud_init::tests::embedded_template_matches_workspace_canonical). Renderer at crates/yah/cloud/src/cloud_init.rs substitutes {{KEY}} (no-spaces) for MACHINE_NAME, YAH_WARDEN_BASE64, HEADSCALE_PREAUTH_KEY, TAGS; doc comments use {{ KEY }} (with spaces) so they survive rendering. find_unsubstituted() trips on any unsubstituted real placeholder. Provision orchestrator at crates/yah/cloud/src/provision.rs decoupled from concrete provider (takes &dyn MachineProvider). CLI wired in app/yah/cli/src/cloud.rs: yah cloud machine provision <name> [--dry-run] [--yubaba <path>]; --dry-run with no flags uses placeholders for the yubaba binary + preauth key (with NOTE lines explaining); live path requires --yubaba + HEADSCALE_PREAUTH_KEY + HETZNER_API_TOKEN. 20 unit tests pass (8 new for cloud_init + provision). Vocab: per-node infra daemon is yah-yubaba (was yah-mirror-agent — renamed 2026-05-01 because mirror is a deployment, not a node).")
50//! @yah:next("A8 yah-yubaba /identity endpoint: poll cloud-init done after Hetzner accepts the create_server call, GET /identity for hostkey fingerprint, write back to .yah/cloud/machines/<name>.toml via MachineConfig::save(). Wiring stub already in handle_provision after rt.block_on — replace the next: print with the real poll loop.")
51//! @yah:next("Optional polish: surface /var/log/cloud-init.log + /var/log/cloud-init-output.log on cloud-init failure (probably via the yah-yubaba /diagnostics endpoint when A8 lands, since direct SSH from the dev machine isn't part of Phase 1).")
52//!
53//! @yah:ticket(R040-T5, "A5: yah cloud machine status — drift report (declared vs Hetzner vs agent health)")
54//! @yah:at(2026-05-05T00:29:11Z)
55//! @yah:status(review)
56//! @yah:phase(P2)
57//! @yah:parent(R040)
58//! @yah:next("Drift categories: missing-server, wrong-machine-type, missing-bucket, hostkey-fingerprint-mismatch, missing-co-tenant, agent-unreachable, service-unhealthy")
59//! @yah:next("Exit non-zero on drift unless --quiet; bare 'yah cloud machine status' dumps all declared machines")
60//! @yah:next("Can land any time after A4 (parallel-safe with A6/A7/A8)")
61//! @yah:verify("Pre-A6: reports 'not provisioned' for each declared machine")
62//! @yah:verify("Post-A6+A7+A8: all three report 'in sync'")
63//! @arch:see(architecture/PHASE_1_MIRROR_BOOTSTRAP.md)
64//! @yah:handoff("Drift module landed at crates/yah/cloud/src/status.rs (DriftFinding + MachineReport + collect_machine_report). MachineProvider trait gained find_server_by_name (GET /servers?name=) + bucket_exists (S3 HEAD) — Hetzner impls share a refactored sign_s3_empty_body helper for PUT/HEAD signing. CLI: `yah cloud machine status [name] [--quiet]` queries Hetzner, prints declared+live state with per-finding [drift]/[note ] tags, and bails non-zero on real drift unless --quiet. Soft findings (missing creds, A8-not-landed, bucket-uncheckable) are surfaced as notes only. Pre-A6 verify: smoke-tested against this workspace's noisetable-pdx-1 declaration with no token — reports 'unknown' + provider-error note, exit 0. 7 new drift unit tests pass via in-memory FakeProvider; 27 total pass in crates/yah/cloud.")
65//! @yah:next("A8 will plug in real agent-ping (yah-yubaba /health + /identity). The HostkeyFingerprintMismatch + AgentUnreachable variants are wired through the report rendering — A8 just needs to flip the AgentUnreachable emit-site in collect_machine_report() from the always-emit pre-A8 stub to a live probe.")
66//!
67//! @yah:ticket(R040-T6, "A6: provision noisetable-{pdx,iad,fsn}-1 (operational; yah dogfoods on noisetable's mirror, no yah-cloud SaaS)")
68//! @yah:at(2026-05-05T00:29:11Z)
69//! @yah:status(review)
70//! @yah:phase(P2)
71//! @yah:parent(R040)
72//! @yah:verify("yah cloud machine status --path /Users/user/ss/noisetable — all three 'in sync'")
73//! @yah:verify("aws s3 ls --endpoint-url <hetzner-region-endpoint> s3://noisetable-assets-<region>-1 — all three list")
74//! @arch:see(architecture/PHASE_1_MIRROR_BOOTSTRAP.md)
75//! @yah:handoff("Code path complete across R040-F1..F8 + cloud-init runcmd alignment. Gated on operator runbook (release tag → live provision against Hetzner). Open repo-config decision (noisetable side, not yah): noisetable/.yah/.gitignore line 3 ignores '/cloud' wholesale — blocks tracking machines/, mirrors/, SECRETS.md. 30 cloud-crate + 17 yah cloud:: tests pass.")
76//! @yah:next("Operator runbook lives in events.jsonl history; the strategic next is the live mirror-status verify (`yah cloud machine status --path /Users/user/ss/noisetable` → all three 'in sync') which is gated on the release-tag + provision steps.")
77//!
78//! @yah:ticket(R040-F7, "A7: yah cloud service deploy — Podman compose generation + Cloudflare front + tier isolation")
79//! @yah:at(2026-05-05T00:29:11Z)
80//! @yah:assignee(agent:claude)
81//! @yah:status(review)
82//! @yah:phase(P3)
83//! @yah:parent(R040)
84//! @yah:next("Resolves service configs + mirror declarations → per-machine compose.yml → push via yah-yubaba (A8) → systemctl restart yah-cloud-services")
85//! @yah:next("Tier isolation at SOFTWARE layer: separate PG roles per service set, distinct Headscale tags (tier:t0 yah meta vs tier:t2 noisetable assets), mesh_only flag controls Cloudflare exposure")
86//! @yah:next("Cloudflare DNS records (manual, in SECRETS.md): pdx.cloud.noisetable.example etc. → public IPs via orange-cloud")
87//! @yah:next("Gotcha: Cloudflare free tier proxies HTTP/HTTPS but NOT raw TCP/UDP — Headscale mesh uses direct WireGuard between machines")
88//! @yah:next("Assumes: Cloudflare account exists + noisetable.example DNS delegated to it (else spawns Cloudflare-bootstrap sub-ticket)")
89//! @yah:next("Open question (refinement): Caddy vs nginx vs direct-to-Cloudflare for per-machine reverse proxy")
90//! @yah:verify("curl https://pdx.cloud.noisetable.example/healthz (asset-registry) returns 200")
91//! @yah:verify("curl https://pdx.cloud.yah.example/healthz (yah meta-directory) returns 200")
92//! @yah:verify("yah cloud service status --all — all green on all 3 machines")
93//! @arch:see(architecture/PHASE_1_MIRROR_BOOTSTRAP.md)
94//! @yah:handoff("Compose generation + yubaba service deploy + CLI wiring landed. New file: crates/yah/cloud/src/compose.rs (generate_compose_bundle: tier-named networks, Caddy service for mesh_only:false, Caddyfile with hostname when cloud_domain is set in mirrors/*.toml). MirrorConfig gained cloud_domain: Option<String>. Yubaba: replaced 501 stubs with real /services (podman compose ps --format json; empty array when no compose.yml) and /compose (write compose.yml + Caddyfile + write yah-cloud-services.service unit + systemctl enable --now). ServerState gained compose_dir: PathBuf (default /etc/yah-cloud) + with_compose_dir() builder. cloud-client: added ComposeDeployRequest / ComposeDeployResponse wire types + deploy_compose() method. CLI: yah cloud service deploy <name> resolves machines via mirror→service lookup, generates per-machine bundle (using location+cloud_domain as public hostname when available), and POSTs to each machine's yubaba. yah cloud service status [name|--all] queries /services on each machine and prints [+]/[-] per container. All tests pass: 18 yubaba, 47 cloud, 8 cloud-client, 120 yah bin.")
95//! @yah:next("Caddy in compose needs a Cloudflare origin cert or Let's Encrypt config once real domains land — Caddyfile currently uses :port placeholders which work for testing. Operator action: set cloud_domain in mirrors/<camp>.toml then re-run yah cloud service deploy to regenerate with real hostnames.")
96//! @yah:next("Cloudflare Tunnel integration (R040-F15): add a cloudflared service to the compose stack — currently services are exposed via Caddy on public ports 80/443 (orange-cloud model). F15 will replace with no-public-port cloudflared tunnel approach.")
97//! @yah:next("podman compose ps --format json output format varies by podman-compose version — current implementation passes raw JSON through. If operators hit parse issues, add a normalization layer in get_services() that extracts Name+State from both podman-compose and Podman 4.x native formats.")
98//! @yah:next("yah cloud service rolling <name> not yet implemented — stub left in place. Compose rolling restarts are just podman compose up --no-deps <svc>; wire it when rolling deploys are needed.")
99//!
100//! @yah:ticket(R040-F8, "A8: yah-yubaba (Rust binary on machine) + yah-cloud-client + desktop Cloud panel")
101//! @yah:at(2026-05-05T00:29:11Z)
102//! @yah:status(review)
103//! @yah:phase(P3)
104//! @yah:parent(R040)
105//! @yah:verify("cargo test -p yubaba -p cloud -p cloud-client + cargo test -p yah --bin yah cloud:: — 18 + 47 + 9 + 17 tests pass")
106//! @yah:verify("cargo run -p yubaba -- serve --bind 127.0.0.1:7450 --state /tmp/i.json then curl POST /register-hostkey with a real ssh-ed25519 pubkey, then `yah cloud machine attach <machine> --host 127.0.0.1:7450 --wait 5 --path <camp>` prints `attached: <machine> (fingerprint recorded: SHA256:…)` and writes the line into .yah/cloud/machines/<name>.toml")
107//! @arch:see(architecture/PHASE_1_MIRROR_BOOTSTRAP.md)
108//! @arch:see(architecture/yah-managed-camps-topology.md)
109//! @yah:handoff("Session 3 landed `yah cloud machine attach <name>` (the hostkey write-back follow-on). app/yah/cli/src/cloud.rs::handle_attach loads MachineConfig, polls cloud-client GET /identity until success or --wait seconds elapse (default 180s, retry every 3s on NotRegistered or Transport errors), then calls MachineConfig::save() with the fingerprint. Decision logic in `decide_attach_action` covers all four states: Write (no fingerprint on file), Idempotent (match — no-op write), Mismatch (bail with --force hint), Overwrite (mismatch + --force). Subcommand args harmonized with agent ping: --host (default http://<machine>:7443), --wait <seconds>, --force, --path. handle_provision's stale stub print (`next: hostkey fingerprint write-back lands with A8`) replaced with `next: once cloud-init finishes, run yah cloud machine attach <name>`. End-to-end smoke verified all four AttachAction paths against a live yubaba on 127.0.0.1:7450 — pre-registration polling + bail, fresh-write, idempotent re-run, mismatch refusal, --force overwrite. 5 new unit tests (decide_attach_action_{writes_when_no_declared,idempotent_on_match,bails_on_mismatch_without_force,overwrites_with_force,force_is_idempotent_when_match}) — total 9 cloud:: tests in the yah bin. Cumulative R040-F8 deliverables across sessions 1–3: yubaba binary (12 tests), cloud-client crate (6 tests + doctest), AgentProbe trait + drift wire-up (4 new in cloud, 29 total), `yah cloud agent {ping,services,logs}` CLI, `yah cloud machine attach`, provision next-step hint correctly points at attach. Out of scope still: Tailscale-mesh discovery, desktop Cloud panel, mTLS hardening.")
110//! @yah:next("Tailscale-mesh discovery for --host: `yah cloud agent ping/services/attach` defaults to http://<machine>:7443 which only resolves inside the mesh. Smart discovery would query `tailscale status --json` for the machine's mesh IP. Until then, operators pass --host explicitly. Add a TS_DEVICES env shortcut or `yah cloud agent ip <machine>` helper if the manual path stays painful.")
111//! @yah:next("Desktop Cloud panel (out of session, larger): packages/yah/ui/src/cloud/ — machines list, per-machine service list, log tail. Tauri command bridges in app/yah/desktop/src/. Pulls on the cloud-client crate (already in workspace). Defer until at least Hostkey write-back lands so the panel has something useful to display per-machine.")
112//! @yah:next("Hardening parking lot (post-Phase-1): mTLS (server cert from machine hostkey, client cert from desktop user identity); /metrics endpoint (Prometheus); streaming /logs from journald with heartbeat. None of these gate Phase 1 verify; cloud-client's CloudClient::with_timeout exists so log streaming has a hook.")
113//! @yah:gotcha("yubaba binds 0.0.0.0:7443 by default; cloud-init template adds ufw rules to restrict to tailscale0. If a future deployment skips ufw, public IP exposure is a real risk. Better long-term: bind to tailscale0 IP directly (cloud-init systemd unit can compute it via `ip -4 addr show tailscale0`).")
114//! @yah:gotcha("Re-running register-hostkey with a different pubkey replaces the stored identity (idempotent for same key, swaps for different). Probably what you want, but worth flagging if someone designs an audit log around 'first registration wins'.")
115//! @yah:gotcha("Default URL resolution (`http://<machine.name>:7443`) is shared between WardenHealthProbe (status drift), agent ping/services, and machine attach — outside Tailscale mesh every default-host call against a real Hetzner machine will fail. Status emits AgentUnreachable as a soft note; agent/attach surface the transport error directly. Once mesh discovery lands, all three call sites should resolve via Tailscale before falling back to plain hostname.")
116//!
117//! @yah:ticket(R040-F18, "Phase 1a — yah mesh start: camp bootstrap with stable URL from day 1")
118//! @yah:at(2026-05-05T00:29:11Z)
119//! @yah:status(review)
120//! @yah:assignee(agent:claude)
121//! @yah:parent(R040)
122//! @yah:phase(P1)
123//! @yah:next("Implements Phase 1a from .yah/docs/architecture/A041-yah-mesh-bootstrap.md — design committed 2026-05-04. Phase 1b lands as R040-F19, Phase 2 (HA) as R040-F20+F21.")
124//! @yah:next("Subcommand surface: `yah mesh start` brings up Headscale locally (yah-desktop or camp daemon); auto-detects direct vs. cloudflared reachability; configures DNS for mesh.<your-domain>; stores URL in vault as `mesh-url` so provision picks it up.")
125//! @yah:next("Cloudflared bundling: yah-desktop should manage the cloudflared binary so the operator doesn't need a separate install. Same daemon can carry yah serve ingress + Headscale tunnel under one install — that's the user-acceptable story.")
126//! @yah:next("Stable URL contract: `mesh.<your-domain>` set ONCE here and never changes across Phase 1b promotion or Phase 2 leader changes. Nodes embed this URL in their tailscale config; downstream phases re-point DNS, not nodes.")
127//! @yah:next("Headscale binary provisioning: download + manage like cloudflared (precedent: yah already shells out to cloudflared/tailscale/cargo). Config template per arch doc 'Headscale config' section. Initial ACL writes a permissive `tag:*` policy.")
128//! @yah:next("Provision integration: when `mesh-url` is set, `yah cloud machine provision` calls Headscale API for a single-use preauth key and embeds {MESH_URL, PREAUTH_KEY, TAGS} into cloud-init's `tailscale up` line. Static `headscale-preauth-key` in vault becomes optional.")
129//! @yah:next("Also lands `yah mesh status` (coordinator location, connected nodes, preauth count). `yah mesh backup`/`restore` can be Phase-1a stubs; full litestream comes with R040-F21.")
130//! @yah:verify("yah mesh start on a fresh camp brings up Headscale + DNS, prints stable URL, stores in vault")
131//! @yah:verify("yah cloud machine provision <name> with mesh-url set generates preauth key and joins the new machine to the camp's Headscale")
132//! @yah:verify("tailscale status on the new machine shows it connected via the camp's tunnel; node-to-node ping works")
133//! @arch:see(.yah/docs/architecture/A041-yah-mesh-bootstrap.md)
134//! @yah:handoff("Phase 1a landed. New files: crates/yah/cloud/src/mesh.rs (HeadscaleClient + generate_headscale_config + headscale_download_url + DEFAULT_ACL_POLICY), app/yah/cli/src/mesh.rs (yah mesh start/status/stop/backup-stub/restore-stub). cloud_init::RenderInput gained mesh_url: Option<String>; renderer substitutes {{MESH_LOGIN_SERVER_ARG}} → ' --login-server <url>' or '' depending on field. provision::build_request now takes mesh_url: Option<String>. template mirror.yml updated with MESH_LOGIN_SERVER_ARG placeholder. cloud.rs::handle_provision now reads mesh-url from vault/HEADSCALE_URL env; when set, calls HeadscaleClient::from_vault_or_env() to auto-generate a single-use preauth key (falls back to static headscale-preauth-key when no API key is available). yah mesh start downloads headscale binary from GitHub (HEADSCALE_VERSION=0.23.0), writes config.yaml + acls.yaml, creates default user, spawns headscale serve in background, saves PID, stores mesh-url in vault, prints DNS/tunnel operator instructions. yah mesh status shows PID, mesh-url, node count + per-node online status. yah mesh stop does SIGTERM+SIGKILL with 5s grace. Cloud crate: 36 tests pass (+4 new: render_with_mesh_url_adds_login_server, render_without_mesh_url_no_login_server, provision::build_request_with_mesh_url_adds_login_server, mesh::config_contains_server_url, mesh::config_all_paths_in_data_dir, mesh::download_url_current_platform). yah lib: 116 unit tests pass (+4 new mesh:: tests). arch dogfood integration tests are pre-existing failures unrelated to this work.")
135//! @yah:next("Operator runbook for Phase 1a: (1) run yah mesh start --url https://mesh.<domain> on the camp; (2) set up DNS + cloudflared (or direct port-forward) for that URL; (3) headscale apikeys create to get an API key; (4) yah keys set headscale-api-key; (5) yah cloud machine provision <name> --yubaba-url <URL> --yubaba <PATH> --path /Users/user/ss/noisetable — provision now auto-generates a preauth key from Headscale.")
136//! @yah:next("Phase 1b (R040-F19): yah mesh promote <machine> — migrates Headscale from camp to first cluster machine. The stable-URL contract from this ticket means no node reconfiguration is needed; only DNS re-points.")
137//! @yah:next("Cloudflared bundling (R040-F15): cloud-init template needs cloudflared install + token. yah mesh start should detect reachability and auto-configure cloudflared when --url points through a Cloudflare Tunnel. The operator-instruction path this session is the MVP fallback.")
138//! @yah:next("Headscale API key auto-generation: currently the operator must manually run headscale apikeys create. A future improvement: yah mesh start can use the local UNIX socket (headscale.sock) to generate the API key and store it in the vault automatically without needing the binary in PATH.")
139//!
140//! @yah:ticket(R040-F19, "Phase 1b — yah mesh promote: migrate Headscale from camp to first cluster machine")
141//! @yah:at(2026-05-05T00:29:11Z)
142//! @yah:assignee(agent:claude)
143//! @yah:status(review)
144//! @yah:phase(P2)
145//! @yah:parent(R040)
146//! @yah:next("Implements Phase 1b from .yah/docs/architecture/A041-yah-mesh-bootstrap.md. Depends on R040-F18 (Phase 1a) shipping the stable-URL contract and camp Headscale.")
147//! @yah:next("Subcommand: yah mesh promote <machine-name>. Refuses if target machine isn't healthy (yubaba /health) or hostkey not registered.")
148//! @yah:next("Migration sequence: (1) stop Headscale on camp, (2) copy SQLite DB + config + private keys to target via yubaba, (3) start Headscale on target as a systemd unit yubaba manages, (4) update Cloudflare DNS A/AAAA for mesh.<your-domain> to point at target, (5) tear down camp Headscale + (if used) cloudflared.")
149//! @yah:next("Single-member raft is the degenerate case at this point — yubaba runs on the target, no quorum partners yet. Acceptable; HA shows up in Phase 2. Mark cluster as 'single-machine' in mesh status output so the operator knows the SPOF is real.")
150//! @yah:next("Atomicity / failure recovery: keep a snapshot of camp Headscale state until target is verified serving traffic. yah mesh promote --abort restores the camp coordinator if the new target proves unhealthy mid-migration. Dry-run mode prints the plan without executing.")
151//! @yah:next("Brief control-plane outage (~10s) is expected during DNS cutover. Data plane (existing WireGuard tunnels) is unaffected — verify by leaving a node-to-node ping running across the migration.")
152//! @yah:next("Out of scope: litestream replication, openraft, multi-member promotion. All of that lands in R040-F20/F21.")
153//! @yah:verify("yah mesh promote noisetable-pdx-1 migrates the coordinator; yah mesh status shows the new location; yah cloud machine provision <new-machine> against the migrated coordinator joins successfully")
154//! @yah:verify("Existing nodes' tailnet connectivity uninterrupted across the migration (continuous ping from one node to another stays green)")
155//! @arch:see(.yah/docs/architecture/A041-yah-mesh-bootstrap.md)
156//! @yah:handoff("Phase 1b landed. New command: yah mesh promote <machine> [--dry-run] [--abort] [--host <URL>] [--path <dir>]. Migration sequence: (1) verify yubaba health + hostkey registered, (2) stop camp headscale, (3) POST SQLite DB + WireGuard keys + ACL policy to yubaba POST /headscale/deploy (yubaba downloads headscale binary from GitHub, writes files to configurable headscale_dir, starts systemd unit), (4) poll GET /headscale/health until running (90s timeout), (5) update Cloudflare DNS A record via CF API (CLOUDFLARE_API_TOKEN + CLOUDFLARE_ZONE_ID) or print manual instructions, (6) persist coordinator type to vault (mesh-coordinator-type=cluster, mesh-coordinator-machine=<name>). --abort restarts camp headscale from existing local state. Rollback on mid-migration failure auto-restarts camp headscale. yah mesh status updated: shows 'cluster [<machine>] (single-machine — SPOF)' after promote, camp PID status before. New yubaba endpoints: POST /headscale/deploy (accepts base64-encoded state files, writes to configurable headscale_dir, curl-downloads binary, systemctl enable --now), GET /headscale/health (systemctl is-active + localhost:8080 HTTP probe). ServerSummary gained public_ipv4: Option<String> (parsed from Hetzner public_net.ipv4.ip). Cloudflare DNS helper: update_cloudflare_dns() + cloudflare_credentials() in cloud::mesh. cloud-client: deploy_headscale() + headscale_health() methods. Tests: +8 yubaba (15 total), +2 cloud-client (9 total), +4 yah mesh:: (120 total). All pass.")
157//! @yah:next("Operator runbook: (1) yah mesh start --url https://mesh.<domain> on camp, (2) provision a machine, (3) yah cloud machine attach <name>, (4) yah mesh promote <name> [--host <yubaba-ip>:7443 pre-mesh]. DNS update needs CLOUDFLARE_API_TOKEN + CLOUDFLARE_ZONE_ID or manual update.")
158//! @yah:next("Mesh URL connectivity: before Tailscale mesh is up, --host must be the machine's public IP (pre-mesh access). Once mesh is established, the default http://<machine>:7443 works. Consider yah cloud agent ip <machine> helper or mesh-discovery to auto-resolve (same gap as R040-F8 Tailscale discovery).")
159//! @yah:next("HEADSCALE_VERSION is pinned to 0.23.0. If the operator's local headscale.db was created by a different version, the remote headscale may reject the database. Add a version-mismatch warning to the deploy step.")
160//!
161//! @yah:relay(R085, "Infra-info MCP — yubaba / machine / mirror status read surface")
162//! @yah:assignee(agent:claude)
163//! @yah:status(review)
164//! @yah:parent(Q082)
165//! @yah:handoff("Four read-only cloud.* MCP tools landed in crates/yah/agent-tools/src/cloud_tools.rs. Registration: KgToolRegistry::with_cloud() in tools.rs (same builder pattern as with_board/with_scryer/with_task). cloud-client dep added to agent-tools Cargo.toml. Local-config tools (cloud.machines, cloud.mirror_state) read .yah/cloud/{machines,mirrors,services}/*.toml from ctx.camp_root with lightweight serde structs — no cloud crate dep, no network. Network tools (cloud.warden_status, cloud.service_ports) probe yubaba HTTP API via cloud_client::CloudClient and return reachable:false rather than error when unreachable. 14 unit tests, all pass. Total agent-tools tests: 140.")
166//! @yah:next("Wire with_cloud() into the agent session startup (camp.rs or agent.rs in app/yah/cli) alongside with_board() and with_scryer() so the tools appear in the agent's tool list automatically.")
167//! @yah:next("Feed cloud.warden_status into the drift detection surface (status.rs AgentProbe) so yubaba reachability populates machine status reports rather than being a separate probe path.")
168//! @yah:verify("cargo test -p agent-tools -- cloud 2>&1 | grep 'test result' shows 14 passed; 0 failed")
169//! @yah:verify("KgToolRegistry::standard_read_only(ctx).with_cloud().schemas() returns 4 tools all named cloud.*")
170//! @arch:see(.yah/quests.md)
171//!
172//! @yah:relay(R168, "yah-cloud TOML config schema + validation")
173//! @yah:assignee(agent:claude)
174//! @yah:at(2026-05-13T19:07:07Z)
175//! @yah:status(review)
176//! @yah:parent(Q156)
177//! @yah:handoff("Schema doc written at .yah/docs/architecture/A031-yah-cloud-config-shape.md. MirrorConfig.camp renamed to .camp with serde alias 'camp' for migration compat (R137 rename). load_mirrors() added to handle both flat mirrors/<id>.toml and folder mirrors/<id>/mirror.toml layouts — yah-com/mirror.toml now loads. cloud_tools.rs local stub updated to match. All 90 cloud-crate tests pass + 4 new layout tests. agent-tools + yah bin check clean.")
178//! @yah:next("Remove serde alias 'camp' from MirrorConfig once all known mirrors are migrated (tracked in R137)")
179//! @yah:verify("cargo test -p cloud — 90 tests pass including mirror_folder_layout_loads + mirror_malformed_fails_with_field_path")
180//! @yah:verify("Schema doc links back to yah-public-site.md deployment-shape section")
181//! @arch:see(.yah/docs/architecture/A031-yah-cloud-config-shape.md)
182//! @arch:see(.yah/docs/architecture/A045-yah-public-site.md)
183//!
184//! @yah:relay(R260, "OpenRouter model almanac — track top-weekly models for backend selection")
185//! @yah:assignee(agent:claude)
186//! @yah:at(2026-05-20T22:23:52Z)
187//! @yah:kind(spike)
188//! @yah:status(in-progress)
189//! @arch:see(architecture/yah-cloud-config-shape.md)
190//! @yah:next("Unique signal: popularity + freshness + capability tags. Pricing is already covered by `crates/yah/probe/data/pricing.json` (LiteLLM snapshot) — almanac does NOT duplicate $/token; it adds rank, `:free` flag, modality, capability tags (code / vision / tool-use / long-context), and a last_seen_at so consumers can tell when an entry has gone stale.")
191//! @yah:next("Primary consumer: the OpenRouter character bundle at `crates/yah/kg/preroll/openrouter/{agents,subclasses}.json`. Currently a single preroll agent (\"Quick Hand\") pinned to `qwen/qwen3-coder:free` — this WILL silently break when OpenRouter rotates the free-tier list. Almanac's first job is to keep that subclass pointed at a currently-available free model (and let the bundle expand into a roster of currently-hot free models, not just one).")
192//! @yah:next("Free-model rotation is the load-bearing constraint. Refresh cadence has to match how often OpenRouter shuffles the `:free` set (empirically weekly-ish). Storage options ranked by how well they handle rotation: (1) scheduled daemon refresh writing back into `crates/yah/kg/preroll/openrouter/` — auto-heals; (2) on-demand fetch+cache in the runner — never stale at session start but offline-fragile; (3) build-time embedded snapshot like `probe/data/pricing.json` — simplest, goes stale between releases (probably wrong shape for free-tier).")
193//! @yah:next("Two approaches to evaluate side-by-side: (A) straight call to `https://openrouter.ai/api/v1/models` (free, no key, structured JSON — already returns `context_length`, `pricing.prompt`/`pricing.completion`, `architecture.modality`; `crates/yah/runner/src/resolver/openrouter.rs` already talks to OpenRouter so a `fetch_models()` sibling to its existing `GET /api/v1/credits` is the natural home); (B) inference-based tool that distills `https://openrouter.ai/models?order=top-weekly` HTML into our schema. Confirm (A) first — if the JSON exposes the top-weekly ordering and `:free` flag, (B) is unnecessary; if not, (B) becomes the rotation-detector.")
194//! @yah:next("Query-resolvable subclass (the real primitive): `AgentSubclass.model` at `crates/yah/kg/src/party.rs:520` is a hard `provider:model` string today. Promote it to a sum type — literal `provider:model` OR `ModelQuery { tier, capability_tags, min_context_tokens, max_price_per_token, … }` — resolved by the almanac at session start. Slots into the existing `fallbacks: Vec<FallbackRule>` machinery at party.rs:540: emit `ConfigSwitch{ModelResolved}` (sibling of `FallbackTriggered`) when the query lands, so the UI can show \"Quick Hand → qwen3-coder:free (current pick, refreshed 2h ago)\". The preroll bundle then declares intent (\"Thief-shaped, free, code-tuned\") instead of a brittle pin.")
195//! @yah:next("Constraint vocabulary — the hard numbers behind \"Thief-shaped, free, code-tuned\", layered by cost-to-implement so we ship something useful at T0 and can grow. **T0 (already free from /models):** `context_length`, `pricing.prompt`/`pricing.completion`, `architecture.modality` come back in the same fetch. MVP heuristic = Pareto frontier of (context_window, price_per_token); at `:free` tier price collapses to 0 so it cleanly reduces to \"max context with matching capability tag\". This is enough to pick a non-broken Quick Hand. **T1 (external quality scores):** join on canonical model id against artificialanalysis.ai's quality index (general + code subscore), Aider's code leaderboard, or LiveBench. Cheap to add once the T0 pipe exists; lets queries express `min_quality` or `prefer_code_subscore`. **T2 (almanac-as-evaluator):** spend a few credits running a small fixed corpus (lint-fix, summarize, ack-and-route) through each new free candidate, score against a reference, persist scores into the almanac. The dogfood option — most signal, most expensive, most interesting. Out of scope for the spike; file as a follow-up child once T0/T1 are in flight.")
196//! @yah:next("Recommendation surface: popularity-ranked picker entries in `packages/yah/ui/src/components/agent/Picker/toPickerAgents.ts` + `AgentProvidersPanel.tsx` so adding an OpenRouter backend surfaces the currently-hot models first; query-resolved subclasses display their resolved pick + a freshness chip; stale literal-pinned `:free` references show a warning.")
197//! @yah:next("Spike output: a design note at `.yah/docs/working/W091-openrouter-almanac.md` + a working (A) prototype printing the top 10 models in our T0 schema (id, context_length, price, `:free` flag, capability tags, popularity rank). From there, refine into child tickets for: (i) refresh mechanism, (ii) `ModelSpec::Literal | ModelSpec::Query` sum type + T0 resolver, (iii) preroll-bundle migration from pinned-model to ModelQuery, (iv) UI recommendation + freshness chips, (v) T1 external-benchmark join, (vi) T2 self-eval as a stretch/follow-up.")
198//! @yah:handoff("Spike output delivered: working prototype + design note + 6 child tickets. Prototype lives in crates/yah/runner/src/resolver/openrouter.rs (AlmanacEntry struct + fetch_models() against /api/frontend/models/find?order=top-weekly + derive_capability_tags) plus app/yah/cli/src/agent.rs (yah agent almanac --top N --free-only --tag code --json). Design note at .yah/docs/working/W091-openrouter-almanac.md. Six follow-ups filed: R260-F1 (refresh scheduler), R260-F2 (ModelSpec sum type + T0 resolver), R260-T3 (preroll bundle migration → ModelSpec::Query), R260-F4 (UI freshness chips + stale-pin warning), R260-F5 (T1 benchmark join), R260-S6 (T2 self-eval, stretch). T0 prototype confirms the rotation problem: `yah agent almanac --top 10 --free-only --tag code` shows qwen/qwen3-coder:free (Quick Hand's current pin) is NO LONGER in the top-10 free code models — current leaders are openrouter/owl-alpha, nvidia/nemotron-3-super, poolside/laguna-m.1.")
199//! @yah:verify("cargo run -p yah -- agent almanac --top 10 — prints OpenRouter top-10 weekly models in T0 schema")
200//! @yah:verify("cargo run -p yah -- agent almanac --top 10 --free-only --tag code — shows the currently-hot free-tier code-tuned roster (the Quick Hand replacement set)")
201//! @yah:verify("cargo check -p runner -p yah — clean (note: cargo test -p runner --lib is blocked by pre-existing R258-F3 test breakage on AgentSession; tracked as gotcha)")
202//! @yah:gotcha("Pre-existing R258-F3 test breakage in crates/yah/runner/src/{anthropic.rs:833, sessions.rs} — `cargo test -p runner --lib` fails with E0063 (AgentSession initialiser missing last_text + slot_id fields). R258-F3 is currently in review. R260's new resolver::openrouter::tests entries are byte-perfect but un-runnable until R258-F3's test fixtures are updated.")
203//! @yah:gotcha("OpenRouter's /api/frontend/models/find?order=top-weekly is undocumented. The almanac treats it as best-effort and the design (see openrouter-almanac.md) calls for graceful degradation to public /api/v1/models alone when the join fails. ?order= is silently ignored on the documented endpoint (it always returns newest-first); approach (B) HTML scraping is unnecessary and was ruled out.")
204//!
205//! @yah:relay(R414, "yah-cloud pill sync + publish + Pills panel")
206//! @yah:assignee(bundle-anthropic-miravel)
207//! @yah:at(2026-06-03T07:07:03Z)
208//! @yah:status(review)
209//! @yah:phase(P2)
210//! @yah:parent(Q410)
211//! @arch:see(.yah/docs/working/W141-pill-rings.md)
212//! @arch:see(.yah/docs/architecture/A031-yah-cloud-config-shape.md)
213//! @yah:depends_on(R411)
214//! @yah:handoff("P2 delivered: resolve_pill_catalog (chips crate) + party_resolve_pill_catalog Tauri command + env wiring + PillsPanel in FullCharacterEditor. Pills panel shows all categorized pills grouped by category with per-character tri-state toggle (always-on/discoverable/off); edits agent.persona.chips.pill_state map via patchAgent.")
215//! @yah:next("P3: local pills.toml loader — add ~/.yah/pills.toml and .yah/pills.toml as new resolution layers (separate from chips.toml) per W141 layer stack")
216//! @yah:next("P3: MCP URL trust display — when a content-pill with mcp[] is toggled always-on, show one-time approval dialog listing the URLs before activating")
217//! @yah:next("P4: cloud sync stub — 'Share to my account' button in PillsPanel (disabled until account system exists)")
218//! @yah:verify("cargo test -p chips — 84 passed (3 new resolve_pill_catalog tests)")
219//! @yah:verify("bun run typecheck — no new errors (6 pre-existing unchanged)")
220//!
221//! @arch:see(.yah/docs/working/W193-asset-dependency-status-surface.md)
222
223pub mod almanac_dispatch;
224pub mod app_manifest;
225pub mod asset_journal;
226pub mod asset_status;
227#[cfg(test)]
228mod asset_status_tests;
229pub mod capability;
230pub mod cloud_init;
231pub mod compose;
232pub mod config;
233pub mod envoy;
234pub mod identities;
235// R374-F3: `local_runtime` + `provider::s3_sign` moved to the `local-driver`
236// crate so yubaba can own MinIO lifecycle without a reverse yubaba→cloud dep.
237// `local_driver_glue` carries the cloud-config adapter that used to live as
238// `LocalContainerSpec::from_provider_config`.
239pub mod local_driver_glue;
240pub mod mesh;
241pub mod mesh_service;
242pub mod multi_root;
243pub mod paths;
244pub mod provider;
245pub mod provision;
246pub mod reconciler;
247pub mod release_manifest;
248pub mod state;
249pub mod status;
250pub mod validate;
251
252pub use almanac_dispatch::dispatch_on_change;
253pub use asset_journal::{AssetState, AssetStatusEvent, AssetStatusJournal};
254pub use capability::Capability;
255pub use compose::{generate_compose_bundle, ComposeBundle};
256pub use config::{
257    BucketLogEntry, CampCloudDbs, CloudConfig, CloudDb, ConnectSpec, DbCatalog, DevDb, GitSource,
258    IngressProvider, LegacyMirrorConfig, LegacyServiceConfig, MachineConfig, MirrorAssignment,
259    MirrorConfig, MirrorProviderSlot, MirrorShape, PondDb, PondDbKind, Provider, ProviderConfig,
260    ServiceComponent, ServiceConfig, TopologyConfig, WorkloadConfig, WorkloadConfigError,
261};
262pub use local_driver::pond_warden::{warden_container_label, warden_container_name};
263pub use local_driver::{
264    canonical_label, canonical_name, ContainerRunSpec, ContainerState, CustomDockerHostProvider,
265    DetectedRuntime, LocalContainerSpec, LocalDockerRuntime, LocalRuntime, OwnedContainer,
266    RuntimePref, RuntimeProvider, SocketRuntimeProvider, LABEL_KEY, NAME_PREFIX,
267};
268pub use local_driver_glue::local_container_spec_from_provider;
269pub use provider::{
270    on_ingress_owner_changed, reconcile_assignment, BucketAcl, BucketRef, CfAccountInfo,
271    CloudflareClient, CloudflareEnvoy, CreateR2BucketResult, CreateTokenResult, CreateTunnelResult,
272    DigitalOceanEnvoy, FloatingIpAssignOutcome, FloatingIpProvider, FloatingIpState,
273    FloatingIpTarget, GrantScope, HetznerDriver, HetznerEnvoy, HetznerFloatingIp, Location,
274    MachineProvider, OvhFloatingIp, ProjectId, R2BucketInfo, R2CustomDomain, ServerId, ServerSpec,
275    ServerStatus, ServerSummary, TokenGrant, TunnelConnState, TunnelDnsRecord, TunnelDriftRow,
276    TunnelDriftState, VultrFloatingIp, WorkerDeployResult, MESOFACT_STATIC_GRANTS,
277};
278#[cfg(feature = "local-docker")]
279pub use provider::{LocalDockerEnvoy, LocalDockerProvider};
280pub use reconciler::{
281    collect_live_derive_hashes, compute_derive_cache_candidates, compute_live_set,
282    compute_prune_candidates, compute_service, derive_minio_key, execute_derive_cache_prune,
283    execute_prune, load_service_and_mirror, mesofact_static::WORKER_SCRIPT, new_sync_id,
284    pond::MINIFLARE_SIM_SCRIPT, publish_to_pond, summarize, CellStatus, CloudflareWorkerReconciler,
285    ContainerOptions, ContainerReconciler, DeriveCacheLiveHashes, DerivePruneCandidate, DriftEntry,
286    HealthState, LocalProcessReconciler, LocalStaticOptions, MesofactStaticReconciler,
287    MirrorObservation, PondOptions, PondPublishReport, PondState, ProviderScope, PruneCandidate,
288    PruneOutcome, PruneReport, ReconcileCtx, Reconciler, RunningWorkload, RunningWorkloadSummary,
289    Runtime, ServiceStatus, StaticAssetReconciler, StatusSummary, SyncHistoryEntry, SyncOutcome,
290    SyncState, WireContainerStatus,
291};
292pub use status::{collect_machine_report, AgentProbe, DriftFinding, MachineReport};