Skip to main content

boatramp_core/
deploy.rs

1//! Content-addressed deployments with atomic activation.
2//!
3//! A **deployment** is an immutable [`Manifest`] mapping site paths to content
4//! hashes. File contents ("blobs") are stored once, keyed by their SHA-256, in
5//! a [`Storage`] backend; the manifest and the per-site "current" pointer live
6//! in a [`KvStore`].
7//!
8//! Publishing is therefore:
9//! 1. upload any blobs the server is missing (dedup is automatic — identical
10//!    bytes share a key),
11//! 2. store the manifest, and
12//! 3. atomically point the site at it.
13//!
14//! Because the pointer write is a single atomic KV operation, a reader always
15//! sees either the previous deployment or the new one in full — never a
16//! half-published mix. Rollback is just pointing the site at an older manifest.
17
18use crate::time::now_unix;
19use std::collections::{BTreeMap, BTreeSet};
20use std::sync::{Arc, Mutex};
21
22use futures::StreamExt;
23use sha2::{Digest, Sha256};
24
25use crate::config::SiteConfig;
26use crate::domain_verify::{DomainVerification, VerificationMethod};
27use crate::error::DeployError;
28use crate::kv::{KvStore, WriteOp};
29use crate::project::{DomainOwner, ProjectRef};
30use crate::site::SiteName;
31use crate::{ByteStream, GetObject, PutMeta, Storage, StorageError};
32
33// The per-file descriptor, its precompressed variants, the immutable
34// content-addressed `Manifest`, and `sha256_hex` are wasm-clean wire types in
35// `boatramp-types` (so the edge Worker + web console share one definition);
36// re-exported so `boatramp_core::deploy::{FileEntry, Variant, Manifest,
37// sha256_hex}` are unchanged. `Manifest`'s methods now return `ConfigError`,
38// which converts to `DeployError` via the existing `From` at `?` sites.
39pub use boatramp_types::file::{FileEntry, Variant};
40pub use boatramp_types::manifest::{sha256_hex, Manifest};
41
42// The deployment **wire structs** — provenance metadata, activation history,
43// and the GC/scrub reports — are pure serde wire types in `boatramp-types` (so
44// the server, CLI, and web console share one definition); re-exported so
45// `boatramp_core::deploy::{DeployMeta, …, ScrubReport}` are unchanged. The
46// `DeployStore` plumbing below that produces them stays here.
47pub use boatramp_types::deploy::{
48    BlobMismatch, BlobReadError, DeployMeta, DeployMetaInput, DeploymentList, GcReport,
49    HistoryEntry, ScrubReport,
50};
51
52/// Tuning for a garbage-collection pass: a safety grace window plus a retention
53/// policy. The defaults are conservative — no retention pressure (all history is
54/// kept) and no grace window — so callers opt into pruning aggressiveness.
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
56pub struct GcOptions {
57    /// Never collect a manifest first-seen within this many seconds, even if it
58    /// is unreferenced. This protects an in-flight deploy whose blobs/manifest
59    /// are uploaded but not yet activated (so not yet reachable from history).
60    pub grace_secs: u64,
61    /// Keep at most this many most-recent history entries per site as
62    /// retention-protected (beyond `current` and aliases, which are always
63    /// kept). `None` keeps the entire history.
64    pub keep_last: Option<usize>,
65    /// Also keep any history entry activated within this many seconds, even
66    /// beyond `keep_last`. `None` applies no age-based retention.
67    pub keep_age_secs: Option<u64>,
68}
69
70/// Most recent activations retained per site.
71const MAX_HISTORY: usize = 100;
72
73/// Canonicalize a routing host: trimmed, no trailing dot, lower-cased. Host names
74/// are case-insensitive and a trailing dot is a legal FQDN form, so the routing
75/// index — and the host-uniqueness guard that protects it — must key on one
76/// canonical form. Otherwise a case- or dot-variant (`Example.com`, `example.com.`)
77/// would write a *second* `domain/<host>` entry and slip past the hijack guard,
78/// then route real traffic when a client sends that exact `Host`.
79fn canon_host(host: &str) -> String {
80    crate::host::Host::new(host).routing_key()
81}
82
83/// Backfill an observed replica record's owning `project` from the scoping key
84/// when a pre-v0.3.12 record left it empty (the `#[serde(default)]` blank). Stamps
85/// both the handle and, when parked (Zero), its snapshot, so the backend derives
86/// the *real* project's identity/IPAM key — never `""` — on adoption/reconcile. A
87/// record that already carries a project is left untouched (idempotent).
88fn backfill_replica_project(state: &mut crate::compute::ObservedInstance, project: &str) {
89    if state.handle.project.is_empty() {
90        state.handle.project = project.to_string();
91    }
92    if let Some(snap) = state.snapshot.as_mut() {
93        if snap.project.is_empty() {
94            snap.project = project.to_string();
95        }
96    }
97}
98
99/// Whether `key` is a sharded blob key (`ab/<64 hex>`). Used so GC never
100/// touches objects it did not write, even in a shared bucket.
101fn is_blob_key(key: &str) -> bool {
102    match key.split_once('/') {
103        Some((shard, hash)) => {
104            shard.len() == 2
105                && hash.len() == 64
106                && shard.bytes().all(|b| b.is_ascii_hexdigit())
107                && hash.bytes().all(|b| b.is_ascii_hexdigit())
108        }
109        None => false,
110    }
111}
112
113/// Ties a blob [`Storage`] to a metadata [`KvStore`] to provide
114/// content-addressed deployments and atomic activation.
115/// A cheaply-cloneable handle to the deploy store. Held per Axum request as
116/// `State<DeployStore>`, so it is **cloned on every request** — a single `Arc`
117/// makes that one atomic bump rather than one per field (the multi-`Arc` clone/drop
118/// was a measurable per-request cost under proxy load). All state lives in
119/// [`DeployStoreInner`]; `Deref` exposes its fields, so methods read `self.kv`
120/// etc. unchanged.
121#[derive(Clone)]
122pub struct DeployStore(Arc<DeployStoreInner>);
123
124impl std::ops::Deref for DeployStore {
125    type Target = DeployStoreInner;
126    fn deref(&self) -> &DeployStoreInner {
127        &self.0
128    }
129}
130
131/// The shared inner state behind [`DeployStore`] (one `Arc`, cloned per request).
132pub struct DeployStoreInner {
133    storage: Arc<dyn Storage>,
134    kv: Arc<dyn KvStore>,
135    /// Serializes host-claim read-modify-writes on the domain routing index
136    /// (`set_site_config` / `attach_verified_domain`), so a `domain/<host>`
137    /// mapping can't be checked-then-overwritten across an `await` and one site
138    /// can't race in to hijack another's domain. Process-local: airtight on a
139    /// single node; a Raft cluster needs the same as a consensus-level
140    /// conditional (noted on [`set_site_config`](Self::set_site_config)).
141    domain_claim_lock: Arc<futures::lock::Mutex<()>>,
142    /// Hot-path cache of parsed site configs, keyed by the immutable
143    /// `siteconfig/<hash>` content hash (a config body never changes under its
144    /// key). The serve path resolves a config on every request; without this it
145    /// re-reads and re-parses the whole `SiteConfig` JSON each time — the dominant
146    /// per-request allocation under load (profiled). Read-mostly `RwLock` (a write
147    /// only on a cache miss / new deploy) so it doesn't add the per-request mutex
148    /// contention a plain `Mutex` would.
149    site_config_cache: Arc<std::sync::RwLock<std::collections::HashMap<String, Arc<SiteConfig>>>>,
150    /// Hot-path cache of small, content-addressed blob *bodies* (the static serve
151    /// path), keyed by the immutable content hash. A hit serves a single refcounted
152    /// `Bytes` frame — no `open`, no `ReaderStream` per-chunk allocation, no disk
153    /// read — which the profile flagged as the dominant static-path allocation.
154    /// Bounded by total bytes; large blobs are never cached (they stream). Same
155    /// read-mostly `RwLock` rationale as [`site_config_cache`].
156    blob_body_cache: Arc<std::sync::RwLock<BlobBodyCache>>,
157    /// Hot-path cache of `Host` → resolved `(project, site)` domain routing, so the
158    /// serve path skips the per-request `domain/<host>` (+ wildcard-suffix walk) KV
159    /// gets. Crucially it caches **misses** too: [`CachedKv`](crate::kv::CachedKv)
160    /// never caches a negative lookup, and the common case — a `Host` with no custom
161    /// domain that falls through to the default site — is all misses, so without this
162    /// every request re-hits the KV up the label chain (profiled as the single
163    /// dominant per-request read under proxy load). Invalidated by `domain_epoch`.
164    domain_cache: Arc<std::sync::RwLock<DomainResolveCache>>,
165    /// Monotonic generation for `domain_cache`, bumped on every domain-index write
166    /// (`set_site_config` / `delete_site`). A resolution cached under an older
167    /// generation is discarded, so a re-pointed or removed host is never served from
168    /// a stale entry — keeping the host-hijack guard exact. Domain changes are rare,
169    /// so a coarse whole-cache epoch beats per-key tracking.
170    domain_epoch: Arc<std::sync::atomic::AtomicU64>,
171}
172
173/// Backing store for [`DeployStore::domain_cache`]: the generation the map was built
174/// under, plus the memoized `Host` → owner resolutions (hits *and* misses).
175#[derive(Default)]
176struct DomainResolveCache {
177    epoch: u64,
178    map: std::collections::HashMap<String, Option<DomainOwner>>,
179}
180
181/// A blob body to serve: a cached, in-memory buffer (small hot assets) or a
182/// streaming handle straight from storage (everything else).
183pub enum BlobBody {
184    /// A small blob served from the in-memory cache as one refcounted frame.
185    Cached(bytes::Bytes),
186    /// A large blob served as one borrowed frame backed by a memory-mapped local
187    /// file — no `tokio::fs` double-buffer copy, and a single content-length body
188    /// instead of a chunked stream. Only local-file backends produce this.
189    Mapped(bytes::Bytes),
190    /// A blob streamed from storage (too large to cache, or Range/large path).
191    Stream(GetObject),
192}
193
194/// Bounded in-memory cache of small blob bodies. Keyed by content hash (immutable,
195/// so a cached body is never stale); bounded by `bytes` total with a
196/// clear-on-overflow policy (no per-entry LRU bookkeeping — the live hot set is
197/// small, and the content-hash keyspace only grows with deploy history).
198#[derive(Default)]
199struct BlobBodyCache {
200    map: std::collections::HashMap<String, bytes::Bytes>,
201    bytes: usize,
202}
203
204/// Blobs at or under this size are eligible for the in-memory body cache; larger
205/// blobs always stream from storage.
206const SMALL_BLOB_CACHE_MAX: u64 = 256 * 1024;
207/// Total byte ceiling for the small-blob body cache.
208const BLOB_BODY_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
209
210pub(crate) mod keys {
211    //! The KV keyspace, collected in one place (mirrors [`boatramp_types::function::keys`]),
212    //! so the persisted layout is legible at a glance instead of scattered through
213    //! [`DeployStore`]'s methods. The strings are the on-disk keyspace — changing one is
214    //! a migration, not a refactor.
215    //!
216    //! Two classes, split by the 0.2.0 **project** re-keying:
217    //!
218    //! - **Project-scoped** (mutable, per-name): a [`ProjectRef`] first arg puts the
219    //!   record under `project/<proj>/…`. The compiler enforces the project is
220    //!   supplied — a site name can't be passed where a project is meant.
221    //! - **Global** (unchanged): content-addressed dedup-shared bodies (`manifests/`,
222    //!   `meta/`, blobs, `siteconfig/`, `daemonconfig/`) — a content hash is a
223    //!   self-authenticating capability, so bodies dedup across projects — and the
224    //!   global-uniqueness domain-routing index (`domain/`, `wildcard/`,
225    //!   `httpchallenge/`), whose **value** now carries the owning `(project, site)`.
226
227    use crate::project::ProjectRef;
228
229    /// A content-addressed manifest: `manifests/<id>` (global CAS).
230    pub fn manifest(id: &str) -> String {
231        format!("manifests/{id}")
232    }
233
234    /// A deployment's [`DeployMeta`](super::DeployMeta): `meta/<id>` (global CAS).
235    pub fn meta(id: &str) -> String {
236        format!("meta/{id}")
237    }
238
239    /// A site's active deployment pointer: `project/<proj>/current/<site>`.
240    pub fn current(project: ProjectRef<'_>, site: &str) -> String {
241        format!("project/{project}/current/{site}")
242    }
243
244    /// The prefix listing a project's active-deployment pointers.
245    pub fn current_prefix(project: ProjectRef<'_>) -> String {
246        format!("project/{project}/current/")
247    }
248
249    /// A named alias → deployment id: `project/<proj>/alias/<site>/<name>`.
250    pub fn alias(project: ProjectRef<'_>, site: &str, name: &str) -> String {
251        format!("project/{project}/alias/{site}/{name}")
252    }
253
254    /// The prefix listing a site's aliases: `project/<proj>/alias/<site>/`.
255    pub fn alias_prefix(project: ProjectRef<'_>, site: &str) -> String {
256        format!("project/{project}/alias/{site}/")
257    }
258
259    /// The prefix listing **every** alias in a project (all sites).
260    pub fn alias_project_prefix(project: ProjectRef<'_>) -> String {
261        format!("project/{project}/alias/")
262    }
263
264    /// A project-scoped internal secret: `project/<proj>/secret/<name>` → an
265    /// envelope-sealed value plus small clear metadata. Project-scoped so a
266    /// `boatramp:<name>` ref resolves only within its own project — never another
267    /// tenant's secrets or the host env. The value is sealed at rest and unsealed
268    /// only at handler/function instantiation; it never leaves over the API.
269    pub fn secret(project: ProjectRef<'_>, name: &str) -> String {
270        format!("project/{project}/secret/{name}")
271    }
272
273    /// The prefix listing a project's internal secrets (for `secrets ls`).
274    pub fn secret_prefix(project: ProjectRef<'_>) -> String {
275        format!("project/{project}/secret/")
276    }
277
278    /// A project-scoped SMTP email profile: `project/<proj>/email/<name>` → the
279    /// clear connection config plus an envelope-sealed password. Project-scoped so
280    /// the `email` guest capability resolves only within its own project's
281    /// profiles — never another tenant's. The password is sealed at rest and
282    /// unsealed only host-side at handler/function instantiation; it never leaves
283    /// over the API (the admin surface returns a password-redacted view).
284    pub fn email_profile(project: ProjectRef<'_>, name: &str) -> String {
285        format!("project/{project}/email/{name}")
286    }
287
288    /// The prefix listing a project's SMTP email profiles (for `email ls`).
289    pub fn email_profile_prefix(project: ProjectRef<'_>) -> String {
290        format!("project/{project}/email/")
291    }
292
293    /// The prefix under which a project's GraphQL **safelist** (persisted trusted
294    /// operations, `hapq/<proj>/<hash>`) lives. Note it is **not** under the
295    /// `project/<proj>/…` resource prefix — the residual sweep in
296    /// [`purge_project`](super::DeployStore::purge_project) does not reach it, so the
297    /// teardown clears it explicitly.
298    pub fn graphql_safelist_prefix(project: ProjectRef<'_>) -> String {
299        format!("hapq/{project}/")
300    }
301
302    /// The prefix under which a project's whole GraphQL registry state lives
303    /// (`graphql/<proj>/…`): subgraph SDLs, per-subgraph backend records, and the
304    /// composition-version counter. Like the safelist, this is **not** under
305    /// `project/<proj>/…`, so the teardown clears it explicitly.
306    pub fn graphql_registry_prefix(project: ProjectRef<'_>) -> String {
307        format!("graphql/{project}/")
308    }
309
310    /// The prefix listing a project's registered GraphQL subgraph SDLs
311    /// (`graphql/<proj>/subgraph/<name>`) — the enumerable, named members of the
312    /// registry (backend records + version key are swept by
313    /// [`graphql_registry_prefix`]).
314    pub fn graphql_subgraph_prefix(project: ProjectRef<'_>) -> String {
315        format!("graphql/{project}/subgraph/")
316    }
317
318    /// Sharded blob key, e.g. `ab/abcdef...`, to avoid one huge directory (global CAS).
319    pub fn blob(hash: &str) -> String {
320        if hash.len() >= 2 {
321            format!("{}/{}", &hash[..2], hash)
322        } else {
323            hash.to_string()
324        }
325    }
326
327    /// The **mutable pointer** for a site: `project/<proj>/site/<site>` → the content
328    /// hash of its current `SiteConfig`. Tiny; the only key that changes on a config
329    /// edit, so it's the only thing the shared-mode invalidation feed must carry.
330    pub fn site_pointer(project: ProjectRef<'_>, site: &str) -> String {
331        format!("project/{project}/site/{site}")
332    }
333
334    /// The prefix listing a project's site-config pointers.
335    pub fn site_prefix(project: ProjectRef<'_>) -> String {
336        format!("project/{project}/site/")
337    }
338
339    /// The **immutable, content-addressed** config body:
340    /// `siteconfig/<hash>` → the `SiteConfig` JSON. Keyed by its own hash, so it
341    /// never changes under a key and is safe to cache forever; identical configs
342    /// across sites (and projects) dedup to one blob. Global CAS.
343    pub fn site_config_blob(hash: &str) -> String {
344        format!("siteconfig/{hash}")
345    }
346
347    /// A registered exact host → owner: `domain/<canon-host>`. The **key** is global
348    /// (hosts are globally unique); the stored **value** is the owning
349    /// `(project, site)` (see [`DomainOwner`](crate::project::DomainOwner)).
350    pub fn domain(host: &str) -> String {
351        format!("domain/{}", super::canon_host(host))
352    }
353
354    /// A registered wildcard suffix → owner: `wildcard/<canon-suffix>` (global key,
355    /// owner in the value).
356    pub fn wildcard(suffix: &str) -> String {
357        format!("wildcard/{}", super::canon_host(suffix))
358    }
359
360    /// A pending domain-ownership challenge:
361    /// `project/<proj>/domainverify/<site>/<verify-host>`.
362    pub fn domain_verification(project: ProjectRef<'_>, site: &str, host: &str) -> String {
363        format!(
364            "project/{project}/domainverify/{site}/{}",
365            crate::domain_verify::normalize_host(host)
366        )
367    }
368
369    /// The prefix listing a site's pending domain challenges.
370    pub fn domain_verification_prefix(project: ProjectRef<'_>, site: &str) -> String {
371        format!("project/{project}/domainverify/{site}/")
372    }
373
374    /// The prefix listing **every** pending domain challenge in a project.
375    pub fn domain_verification_project_prefix(project: ProjectRef<'_>) -> String {
376        format!("project/{project}/domainverify/")
377    }
378
379    /// Index key mapping an **HTTP challenge** `(host, token)` → its owner, so the
380    /// unauthenticated self-serve edge route is an O(1) lookup rather than an O(N)
381    /// scan of every site's challenges (a flood-amplification vector). The token
382    /// is a 128-bit random, so carrying it in the key is safe. Global key; the value
383    /// carries the owning `(project, site)`.
384    pub fn http_challenge_index(host: &str, token: &str) -> String {
385        format!(
386            "httpchallenge/{}/{token}",
387            crate::domain_verify::normalize_host(host)
388        )
389    }
390
391    /// The immutable, content-addressed daemon-config body: `daemonconfig/<hash>`
392    /// (global CAS + a control-plane singleton).
393    pub fn daemon_config_blob(hash: &str) -> String {
394        format!("daemonconfig/{hash}")
395    }
396
397    /// A site's activation history list: `project/<proj>/history/<site>`.
398    pub fn history(project: ProjectRef<'_>, site: &str) -> String {
399        format!("project/{project}/history/{site}")
400    }
401
402    /// The prefix listing a project's activation-history lists.
403    pub fn history_prefix(project: ProjectRef<'_>) -> String {
404        format!("project/{project}/history/")
405    }
406
407    /// The root prefix under which **all** project-scoped records live. A cross-
408    /// project fan-out (`_all` list variants, GC) discovers the project set by
409    /// scanning this and splitting out the segment after it.
410    pub const PROJECT_ROOT: &str = "project/";
411
412    /// The project name owning a project-scoped `key` (the segment right after
413    /// [`PROJECT_ROOT`]), or `None` if `key` is not project-scoped.
414    pub fn project_of_key(key: &str) -> Option<&str> {
415        key.strip_prefix(PROJECT_ROOT)
416            .and_then(|rest| rest.split('/').next())
417            .filter(|p| !p.is_empty())
418    }
419}
420
421/// A structured, serializable enumeration of everything a project owns —
422/// [`DeployStore::enumerate_project_resources`]'s output. It is the single source of
423/// truth shared by the `project rm --force` **dry-run preview** (rendered to the
424/// operator, nothing deleted) and the **cascade loop** (which walks it in order to
425/// tear the project down), so the preview and the executed teardown can never drift.
426#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
427pub struct ProjectTeardownPlan {
428    /// The project this plan is for.
429    pub project: String,
430    /// The project's site names (each `delete_site` also frees the site's global
431    /// `domain/*`/`wildcard/*` routing claims).
432    pub sites: Vec<String>,
433    /// The project's function names (each `delete_function` sweeps its whole subtree).
434    pub functions: Vec<String>,
435    /// The project's compute workloads, each with the persistent volume names its
436    /// active spec mounts (captured before teardown drops the spec pointer).
437    pub compute: Vec<ComputeTeardown>,
438    /// The project's internal secret names.
439    pub secrets: Vec<String>,
440    /// The number of GraphQL safelist (persisted-operation) entries.
441    pub safelist: usize,
442    /// The project's registered GraphQL subgraph names.
443    pub subgraphs: Vec<String>,
444    /// Any remaining `project/<proj>/<family>/…` key families not surfaced as a
445    /// dedicated field above, counted per family — a forward-compatible catch-all so a
446    /// newly-added resource family is still previewed and swept.
447    pub other_families: std::collections::BTreeMap<String, usize>,
448}
449
450impl ProjectTeardownPlan {
451    /// Whether the project owns nothing at all (every family empty) — used to decide
452    /// the `404` for a force-delete of a project that never existed and owns nothing.
453    pub fn is_empty(&self) -> bool {
454        self.sites.is_empty()
455            && self.functions.is_empty()
456            && self.compute.is_empty()
457            && self.secrets.is_empty()
458            && self.safelist == 0
459            && self.subgraphs.is_empty()
460            && self.other_families.is_empty()
461    }
462
463    /// Every persistent-volume name referenced across the plan's compute workloads
464    /// (de-duplicated, sorted) — the volumes the cascade reclaims once the workloads
465    /// that mounted them are gone.
466    pub fn all_volumes(&self) -> Vec<String> {
467        let mut vols: std::collections::BTreeSet<String> = Default::default();
468        for c in &self.compute {
469            for v in &c.volumes {
470                vols.insert(v.clone());
471            }
472        }
473        vols.into_iter().collect()
474    }
475}
476
477/// One compute workload in a [`ProjectTeardownPlan`]: its name plus the persistent
478/// volume names its active spec mounts (reclaimed after the workload is deleted).
479#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
480pub struct ComputeTeardown {
481    /// The workload name.
482    pub name: String,
483    /// The persistent volume names the workload's active spec mounts.
484    pub volumes: Vec<String>,
485}
486
487impl DeployStore {
488    /// Build a deploy store over a blob `storage` and a metadata `kv`.
489    pub fn new(storage: Arc<dyn Storage>, kv: Arc<dyn KvStore>) -> Self {
490        Self(Arc::new(DeployStoreInner {
491            storage,
492            kv,
493            domain_claim_lock: Arc::new(futures::lock::Mutex::new(())),
494            site_config_cache: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
495            blob_body_cache: Arc::new(std::sync::RwLock::new(BlobBodyCache::default())),
496            domain_cache: Arc::new(std::sync::RwLock::new(DomainResolveCache::default())),
497            domain_epoch: Arc::new(std::sync::atomic::AtomicU64::new(0)),
498        }))
499    }
500
501    /// The underlying metadata store, for control-plane features that keep their own
502    /// key namespace (e.g. the GraphQL subgraph registry) rather than a deploy record.
503    pub fn kv(&self) -> &Arc<dyn KvStore> {
504        &self.kv
505    }
506
507    /// Readiness probe: confirm the metadata backend is reachable with a cheap
508    /// read (a missing key is fine — it still proves the backend answered). The
509    /// blob backend is exercised per-request rather than probed here.
510    pub async fn ready(&self) -> Result<(), DeployError> {
511        self.kv.get("__readyz_probe__").await?;
512        Ok(())
513    }
514
515    /// Store a manifest (idempotent) and return its deployment id.
516    pub async fn put_manifest(&self, manifest: &Manifest) -> Result<String, DeployError> {
517        self.put_manifest_with(manifest, DeployMetaInput::default())
518            .await
519    }
520
521    /// Store a manifest (idempotent) and record/refresh its [`DeployMeta`].
522    ///
523    /// `created_at` is set on first store and preserved across re-deploys of the
524    /// same content (so the GC grace window measures true age); sizes are
525    /// recomputed from the manifest, and the client-supplied provenance fields
526    /// are merged in (a later deploy of identical content can update its source/
527    /// message without resetting `created_at`).
528    pub async fn put_manifest_with(
529        &self,
530        manifest: &Manifest,
531        input: DeployMetaInput,
532    ) -> Result<String, DeployError> {
533        let id = manifest.id()?;
534
535        // Compute the metadata first (it merges with any prior record), then
536        // commit the manifest and its metadata together: one durable flush,
537        // and a reader never sees the manifest without its companion meta.
538        let existing = self.get_meta(&id).await?;
539        let created_at = existing
540            .as_ref()
541            .map(|m| m.created_at)
542            .unwrap_or_else(now_unix);
543        let meta = DeployMeta {
544            version: crate::SCHEMA_VERSION,
545            created_at,
546            file_count: manifest.files.len() as u64,
547            total_size: manifest.files.values().map(|entry| entry.size).sum(),
548            source: input
549                .source
550                .or_else(|| existing.as_ref().and_then(|m| m.source.clone())),
551            branch: input
552                .branch
553                .or_else(|| existing.as_ref().and_then(|m| m.branch.clone())),
554            author: input
555                .author
556                .or_else(|| existing.as_ref().and_then(|m| m.author.clone())),
557            message: input
558                .message
559                .or_else(|| existing.as_ref().and_then(|m| m.message.clone())),
560            tag: input
561                .tag
562                .or_else(|| existing.as_ref().and_then(|m| m.tag.clone())),
563            // Tags replace wholesale when supplied (so a re-deploy can retag);
564            // an empty input preserves the prior set, mirroring the fields above.
565            tags: if input.tags.is_empty() {
566                existing.map(|m| m.tags).unwrap_or_default()
567            } else {
568                input.tags
569            },
570        };
571        self.kv
572            .write_batch(vec![
573                WriteOp::Put(keys::manifest(&id), manifest.to_bytes()?),
574                WriteOp::Put(keys::meta(&id), serde_json::to_vec(&meta)?),
575            ])
576            .await?;
577        Ok(id)
578    }
579
580    /// Fetch a deployment's [`DeployMeta`], if recorded.
581    pub async fn get_meta(&self, id: &str) -> Result<Option<DeployMeta>, DeployError> {
582        match self.kv.get(&keys::meta(id)).await? {
583            Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
584            None => Ok(None),
585        }
586    }
587
588    /// Fetch a manifest by deployment id.
589    pub async fn get_manifest(&self, id: &str) -> Result<Option<Manifest>, DeployError> {
590        match self.kv.get(&keys::manifest(id)).await? {
591            Some(bytes) => Ok(Some(Manifest::from_bytes(&bytes)?)),
592            None => Ok(None),
593        }
594    }
595
596    /// Resolve a deployment-id **prefix** to the one full id it uniquely names
597    /// (an exact id resolves to itself). Used for the wildcard preview host form
598    /// `<id>.deploy.<host>`, where the id rides as a DNS label — capped at 63
599    /// chars, shorter than a full 64-hex content hash — so operators use a
600    /// prefix. Returns `None` if nothing matches; `Err(Ambiguous)` if the prefix
601    /// is not unique (the caller should treat that as not-found).
602    pub async fn resolve_manifest_id(&self, prefix: &str) -> Result<Option<String>, DeployError> {
603        // Exact hit first (the common case; avoids a scan).
604        if self.kv.get(&keys::manifest(prefix)).await?.is_some() {
605            return Ok(Some(prefix.to_string()));
606        }
607        let key_prefix = keys::manifest(prefix);
608        let strip = "manifests/".len();
609        let keys = self.kv.list_prefix(&key_prefix).await?;
610        let mut ids = keys.iter().map(|key| &key[strip..]);
611        match (ids.next(), ids.next()) {
612            (Some(only), None) => Ok(Some(only.to_string())),
613            (Some(_), Some(_)) => Err(DeployError::Ambiguous(prefix.to_string())),
614            _ => Ok(None),
615        }
616    }
617
618    /// Whether a blob with `hash` is already stored.
619    pub async fn has_blob(&self, hash: &str) -> Result<bool, DeployError> {
620        match self.storage.head(&keys::blob(hash)).await {
621            Ok(_) => Ok(true),
622            Err(StorageError::NotFound(_)) => Ok(false),
623            Err(err) => Err(err.into()),
624        }
625    }
626
627    /// The blob hashes from `manifest` that the store is missing.
628    pub async fn missing_blobs(&self, manifest: &Manifest) -> Result<Vec<String>, DeployError> {
629        let mut missing = Vec::new();
630        for hash in manifest.blob_hashes() {
631            if !self.has_blob(&hash).await? {
632                missing.push(hash);
633            }
634        }
635        Ok(missing)
636    }
637
638    /// Stream a blob into storage, verifying it hashes to `hash`.
639    ///
640    /// The bytes are hashed as they pass through to the backend (never fully
641    /// buffered). A mismatch deletes the partial blob and errors.
642    pub async fn put_blob(&self, hash: &str, body: ByteStream) -> Result<(), DeployError> {
643        let hasher = Arc::new(Mutex::new(Sha256::new()));
644        let tap = hasher.clone();
645        let verified: ByteStream = body
646            .map(move |chunk| {
647                if let Ok(bytes) = &chunk {
648                    tap.lock().unwrap().update(bytes);
649                }
650                chunk
651            })
652            .boxed();
653
654        let key = keys::blob(hash);
655        self.storage.put(&key, verified, PutMeta::default()).await?;
656
657        let actual = hex::encode(hasher.lock().unwrap().clone().finalize());
658        if actual != hash {
659            let _ = self.storage.delete(&key).await;
660            return Err(DeployError::HashMismatch {
661                expected: hash.to_string(),
662                actual,
663            });
664        }
665        Ok(())
666    }
667
668    /// Open a blob for streaming reads.
669    pub async fn open_blob(&self, hash: &str) -> Result<GetObject, DeployError> {
670        Ok(self.storage.get(&keys::blob(hash)).await?)
671    }
672
673    /// The open local file backing a content-addressed blob, for the zero-copy
674    /// `sendfile` static path — `None` on remote/opaque backends (the caller then
675    /// uses [`open_blob_cached`](DeployStore::open_blob_cached)). The blob keyspace
676    /// is content-addressed and immutable, so the handle never sees a rewrite.
677    pub fn blob_file(&self, hash: &str) -> Option<std::fs::File> {
678        self.storage.local_file(&keys::blob(hash))
679    }
680
681    /// Serve a blob body, using the in-memory small-blob cache for the static hot
682    /// path. Blobs over [`SMALL_BLOB_CACHE_MAX`] always stream
683    /// ([`BlobBody::Stream`]); small blobs are served from the content-hash cache
684    /// ([`BlobBody::Cached`]), read + cached only on a miss. A hit is a refcounted
685    /// `Bytes` clone — no `open`, no `ReaderStream` per-chunk allocation, no disk
686    /// read. The content hash is immutable, so a cached body never goes stale.
687    pub async fn open_blob_cached(&self, hash: &str, size: u64) -> Result<BlobBody, DeployError> {
688        use futures::TryStreamExt;
689        if size > SMALL_BLOB_CACHE_MAX {
690            // Too big to cache in memory. On a local-file backend, memory-map it and
691            // serve one borrowed frame — skips `tokio::fs`'s double-buffer copy and
692            // sends a content-length body, not a chunked stream. Remote/opaque
693            // backends (S3/GCS/Azure) return `None` here and stream instead.
694            if let Some(bytes) = self.storage.mapped(&keys::blob(hash)) {
695                return Ok(BlobBody::Mapped(bytes));
696            }
697            return Ok(BlobBody::Stream(self.open_blob(hash).await?));
698        }
699        if let Some(bytes) = self.blob_body_cache.read().unwrap().map.get(hash).cloned() {
700            return Ok(BlobBody::Cached(bytes));
701        }
702        // Miss: read the whole (small) blob once into an owned, refcounted buffer —
703        // one allocation, versus `ReaderStream`'s per-chunk churn on every request.
704        let object = self.storage.get(&keys::blob(hash)).await?;
705        let mut buf = bytes::BytesMut::with_capacity(size as usize);
706        let mut body = object.body;
707        while let Some(chunk) = body.try_next().await? {
708            buf.extend_from_slice(&chunk);
709        }
710        let bytes = buf.freeze();
711        {
712            let mut cache = self.blob_body_cache.write().unwrap();
713            // Clear-on-overflow keeps the byte bound without per-entry LRU bookkeeping.
714            if cache.bytes.saturating_add(bytes.len()) > BLOB_BODY_CACHE_MAX_BYTES {
715                cache.map.clear();
716                cache.bytes = 0;
717            }
718            if cache.map.insert(hash.to_string(), bytes.clone()).is_none() {
719                cache.bytes = cache.bytes.saturating_add(bytes.len());
720            }
721        }
722        Ok(BlobBody::Cached(bytes))
723    }
724
725    /// Open a byte range of a blob for streaming reads (HTTP `Range`).
726    pub async fn open_blob_range(
727        &self,
728        hash: &str,
729        offset: u64,
730        len: Option<u64>,
731    ) -> Result<GetObject, DeployError> {
732        Ok(self
733            .storage
734            .get_range(&keys::blob(hash), offset, len)
735            .await?)
736    }
737
738    /// A site's [`SiteConfig`], if it has been set. Reads the `site/<site>`
739    /// pointer, then the immutable `siteconfig/<hash>` body it names.
740    pub async fn get_site_config(
741        &self,
742        project: ProjectRef<'_>,
743        site: &str,
744    ) -> Result<Option<SiteConfig>, DeployError> {
745        let Some(hash) = self.kv.get(&keys::site_pointer(project, site)).await? else {
746            return Ok(None);
747        };
748        let hash = String::from_utf8_lossy(&hash).into_owned();
749        match self.kv.get(&keys::site_config_blob(&hash)).await? {
750            Some(bytes) => Ok(Some(SiteConfig::from_json(&bytes)?)),
751            // A dangling pointer (body GC'd out from under it) reads as unset.
752            None => Ok(None),
753        }
754    }
755
756    /// Like [`get_site_config`](Self::get_site_config) but returns a shared,
757    /// **cached** parse for the hot serve path. Reads the mutable `site/<site>`
758    /// pointer (small) to learn the current content hash, then serves the parsed
759    /// `Arc<SiteConfig>` from the content-hash cache — reading + parsing the body
760    /// only on a miss (a first request or a fresh deploy). The owned
761    /// [`get_site_config`](Self::get_site_config) stays for mutations/admin/tests.
762    pub async fn get_site_config_cached(
763        &self,
764        project: ProjectRef<'_>,
765        site: &str,
766    ) -> Result<Option<Arc<SiteConfig>>, DeployError> {
767        let Some(hash) = self.kv.get(&keys::site_pointer(project, site)).await? else {
768            return Ok(None);
769        };
770        let hash = String::from_utf8_lossy(&hash).into_owned();
771        if let Some(cfg) = self.site_config_cache.read().unwrap().get(&hash).cloned() {
772            return Ok(Some(cfg));
773        }
774        let Some(bytes) = self.kv.get(&keys::site_config_blob(&hash)).await? else {
775            return Ok(None); // dangling pointer (body GC'd) — treat as unset
776        };
777        let cfg = Arc::new(SiteConfig::from_json(&bytes)?);
778        {
779            let mut cache = self.site_config_cache.write().unwrap();
780            // The content-hash keyspace grows with deploy history; the live working
781            // set is tiny (one hash per served site), so a simple cap + clear bounds
782            // memory without an LRU's per-request bookkeeping.
783            if cache.len() >= 512 {
784                cache.clear();
785            }
786            cache.insert(hash, Arc::clone(&cfg));
787        }
788        Ok(Some(cfg))
789    }
790
791    /// Store a site's [`SiteConfig`] and rebuild its host → site index entries
792    /// (so `resolve_site_by_host` can route by `Host`).
793    ///
794    /// Content-addressed: the config body is written
795    /// once under `siteconfig/<hash>` (immutable, dedup'd) and the mutable
796    /// `site/<site>` pointer is flipped to it. Only the tiny pointer changes, so
797    /// the shared-mode invalidation surface is the pointer, not the body. The
798    /// whole change (drop old index, write body, flip pointer, write new index)
799    /// commits as one atomic batch.
800    ///
801    /// **Host uniqueness (hijack guard):** a host — or wildcard suffix — already
802    /// mapped to a *different* site is refused with [`DeployError::Conflict`]
803    /// rather than silently overwritten. Without this, any site-writer could
804    /// point another site's live domain at their own site (last-writer-wins
805    /// takeover). The read-check and the write are serialized by a process-local
806    /// lock so they can't be interleaved across an `await`. This is airtight on a
807    /// single node; a Raft cluster holds it per node, and a cross-node claim race
808    /// would additionally need a consensus-level conditional apply — a documented
809    /// follow-up, not reachable in the dominant single-node topology.
810    pub async fn set_site_config(
811        &self,
812        project: ProjectRef<'_>,
813        site: &str,
814        config: &SiteConfig,
815    ) -> Result<(), DeployError> {
816        let _claim = self.domain_claim_lock.lock().await;
817        self.set_site_config_locked(project, site, config).await
818    }
819
820    /// [`set_site_config`](Self::set_site_config) assuming the domain-claim lock
821    /// is **already held** — so [`attach_verified_domain`](Self::attach_verified_domain)
822    /// can extend a config under the same lock without re-entering it (the lock
823    /// is not reentrant).
824    async fn set_site_config_locked(
825        &self,
826        project: ProjectRef<'_>,
827        site: &str,
828        config: &SiteConfig,
829    ) -> Result<(), DeployError> {
830        let owner = DomainOwner::new(project.as_str(), site);
831        // Refuse any host/wildcard already claimed by another (project, site) before
832        // writing anything (the hijack guard). A host this site already owns, or one
833        // that is unclaimed, passes.
834        for host in config.domains.exact_hosts() {
835            self.ensure_host_claimable(&keys::domain(host), host, &owner)
836                .await?;
837        }
838        for wildcard in &config.domains.wildcards {
839            if let Some(suffix) = wildcard.strip_prefix("*.") {
840                self.ensure_host_claimable(&keys::wildcard(suffix), wildcard, &owner)
841                    .await?;
842            }
843        }
844
845        let body = config.to_json()?;
846        let hash = sha256_hex(&body);
847
848        let mut ops = Vec::new();
849        if let Some(old) = self.get_site_config(project, site).await? {
850            for host in old.domains.exact_hosts() {
851                ops.push(WriteOp::Delete(keys::domain(host)));
852            }
853            for wildcard in &old.domains.wildcards {
854                if let Some(suffix) = wildcard.strip_prefix("*.") {
855                    ops.push(WriteOp::Delete(keys::wildcard(suffix)));
856                }
857            }
858        }
859
860        // Immutable body (idempotent put) + the mutable pointer flip.
861        ops.push(WriteOp::Put(keys::site_config_blob(&hash), body));
862        ops.push(WriteOp::Put(
863            keys::site_pointer(project, site),
864            hash.into_bytes(),
865        ));
866
867        // The domain-routing index value carries the owning `(project, site)` so a request `Host`
868        // resolves to both (the key stays global — hosts are unique), plus the per-host **tenant
869        // context tag** (Stage 0): a host's own `contexts` entry, else the primary's (apex↔www
870        // share a tenant), else none. A wildcard carries its own pattern's tag; matching
871        // subdomains inherit it via resolution. This is the declarative domain tenant source.
872        let contexts = &config.domains.contexts;
873        let primary_ctx = config
874            .domains
875            .primary
876            .as_deref()
877            .and_then(|p| contexts.get(p));
878        let owner_for = |key: &str| -> Vec<u8> {
879            let ctx = contexts.get(key).or(primary_ctx);
880            match ctx {
881                Some(tag) => owner.clone().with_context(tag.clone()).to_bytes(),
882                None => owner.to_bytes(),
883            }
884        };
885        for host in config.domains.exact_hosts() {
886            ops.push(WriteOp::Put(keys::domain(host), owner_for(host)));
887        }
888        for wildcard in &config.domains.wildcards {
889            if let Some(suffix) = wildcard.strip_prefix("*.") {
890                // The wildcard's own tag only (a wildcard is not an "alias" of the primary).
891                let bytes = match contexts.get(wildcard) {
892                    Some(tag) => owner.clone().with_context(tag.clone()).to_bytes(),
893                    None => owner.to_bytes(),
894                };
895                ops.push(WriteOp::Put(keys::wildcard(suffix), bytes));
896            }
897        }
898        self.kv.write_batch(ops).await?;
899        // The domain index changed — drop cached `Host` resolutions built against the
900        // old generation so the new/removed mappings take effect on the next request.
901        self.bump_domain_epoch();
902        Ok(())
903    }
904
905    /// Error with [`DeployError::Conflict`] if index `key` (a `domain/<host>` or
906    /// `wildcard/<suffix>` entry) is already held by an owner other than `owner`.
907    /// `label` is the host as written, for the message. Must be called with the
908    /// domain-claim lock held. The stored value is a tolerant
909    /// [`DomainOwner`] (a bare-string legacy value reads as the `default` project).
910    async fn ensure_host_claimable(
911        &self,
912        key: &str,
913        label: &str,
914        owner: &DomainOwner,
915    ) -> Result<(), DeployError> {
916        if let Some(bytes) = self.kv.get(key).await? {
917            let held = DomainOwner::from_bytes(&bytes);
918            if &held != owner {
919                return Err(DeployError::Conflict(format!(
920                    "{label} is already attached to site `{}` in project `{}`",
921                    held.site, held.project
922                )));
923            }
924        }
925        Ok(())
926    }
927
928    /// Resolve a request `Host` to its owning `(project, site)`: exact match first,
929    /// then wildcard suffixes from most specific to least (so `*.example.com`
930    /// matches `a.b.example.com`). The index value is a tolerant [`DomainOwner`]
931    /// (a legacy bare-string value reads as the `default` project).
932    pub async fn resolve_site_by_host(
933        &self,
934        host: &str,
935    ) -> Result<Option<DomainOwner>, DeployError> {
936        // Canonicalize once so the `Host` a client sends matches the canonical
937        // key written by `set_site_config` regardless of case / trailing dot.
938        let host = canon_host(host);
939        let host = host.as_str();
940        // Fast path: a resolution cached under the current domain generation. Covers
941        // both hits and misses, so a default-site host (no custom domain) is served
942        // without re-walking the label chain in the KV on every request.
943        let epoch = self.domain_epoch.load(std::sync::atomic::Ordering::Acquire);
944        {
945            let cache = self.domain_cache.read().unwrap();
946            if cache.epoch == epoch {
947                if let Some(owner) = cache.map.get(host) {
948                    return Ok(owner.clone());
949                }
950            }
951        }
952        let resolved = self.resolve_site_by_host_uncached(host).await?;
953        // Cache the resolution — but only if no domain-index write raced our KV reads
954        // (else it may be stale). Reset the map when we observe a newer generation so
955        // it never serves entries built against a superseded index.
956        {
957            let mut cache = self.domain_cache.write().unwrap();
958            let current = self.domain_epoch.load(std::sync::atomic::Ordering::Acquire);
959            if cache.epoch != current {
960                cache.epoch = current;
961                cache.map.clear();
962            }
963            if current == epoch {
964                // A large flood of distinct `Host` values (e.g. probing) would grow the
965                // map unbounded; a simple cap + clear bounds memory without per-entry
966                // LRU bookkeeping, mirroring `site_config_cache`.
967                if cache.map.len() >= 4096 {
968                    cache.map.clear();
969                }
970                cache.map.insert(host.to_string(), resolved.clone());
971            }
972        }
973        Ok(resolved)
974    }
975
976    /// The uncached `Host` → owner resolution: exact `domain/<host>`, then each
977    /// wildcard suffix up the label chain. The KV source of truth behind
978    /// [`resolve_site_by_host`](Self::resolve_site_by_host)'s cache.
979    async fn resolve_site_by_host_uncached(
980        &self,
981        host: &str,
982    ) -> Result<Option<DomainOwner>, DeployError> {
983        if let Some(bytes) = self.kv.get(&keys::domain(host)).await? {
984            return Ok(Some(DomainOwner::from_bytes(&bytes)));
985        }
986        let mut rest = host;
987        while let Some((_, parent)) = rest.split_once('.') {
988            if let Some(bytes) = self.kv.get(&keys::wildcard(parent)).await? {
989                return Ok(Some(DomainOwner::from_bytes(&bytes)));
990            }
991            rest = parent;
992        }
993        Ok(None)
994    }
995
996    /// Invalidate [`Self::domain_cache`] after a domain-index mutation by advancing
997    /// the generation. The next resolve sees the bump and rebuilds from the KV.
998    fn bump_domain_epoch(&self) {
999        self.domain_epoch
1000            .fetch_add(1, std::sync::atomic::Ordering::Release);
1001    }
1002
1003    /// Every known site name in `project`, sorted and de-duplicated. A site is
1004    /// "known" if it has a current deployment, a config, or activation history —
1005    /// so a configured-but-not-yet-deployed site (or vice versa) still appears.
1006    /// Backs `GET /api/sites`. (Broader than [`list_sites`](Self::list_sites),
1007    /// which is just the currently-deployed sites the scheduler runs.)
1008    pub async fn all_sites(&self, project: ProjectRef<'_>) -> Result<Vec<String>, DeployError> {
1009        let mut sites = BTreeSet::new();
1010        for prefix in [
1011            keys::current_prefix(project),
1012            keys::site_prefix(project),
1013            keys::history_prefix(project),
1014        ] {
1015            for key in self.kv.list_prefix(&prefix).await? {
1016                if let Some(name) = key.strip_prefix(&prefix) {
1017                    if !name.is_empty() {
1018                        sites.insert(name.to_string());
1019                    }
1020                }
1021            }
1022        }
1023        Ok(sites.into_iter().collect())
1024    }
1025
1026    /// Every `(project, site)` known across **all** projects — the cross-project
1027    /// fan-out backing the scheduler and admin surfaces. Discovers the project set
1028    /// by scanning [`keys::PROJECT_ROOT`], then unions each project's `all_sites`.
1029    pub async fn all_sites_all(&self) -> Result<Vec<(String, String)>, DeployError> {
1030        let mut out = Vec::new();
1031        for project in self.discover_projects().await? {
1032            for site in self.all_sites(ProjectRef::new(&project)).await? {
1033                out.push((project.clone(), site));
1034            }
1035        }
1036        Ok(out)
1037    }
1038
1039    /// The distinct project names with any record in the store (the segment after
1040    /// [`keys::PROJECT_ROOT`]). Independent of whether a `projectmeta/<name>`
1041    /// pointer exists, so a fan-out reaches projects created only implicitly (e.g.
1042    /// pre-migration data re-keyed under `default`).
1043    pub async fn discover_projects(&self) -> Result<Vec<String>, DeployError> {
1044        let mut projects = BTreeSet::new();
1045        for key in self.kv.list_prefix(keys::PROJECT_ROOT).await? {
1046            if let Some(p) = keys::project_of_key(&key) {
1047                projects.insert(p.to_string());
1048            }
1049        }
1050        Ok(projects.into_iter().collect())
1051    }
1052
1053    // ---- Top-level functions (PLAN-faas FA-2) --------------------------------
1054    // Independently-versioned functions stored as one JSON record per name under
1055    // `project/<proj>/functions/<name>`, referencing content-addressed component
1056    // blobs. Reuses the blob store + KV; the deploy/alias immutability model applies
1057    // (a version id is its component's content hash).
1058
1059    /// Store a top-level function's record (its versions, active, aliases).
1060    pub async fn put_function(
1061        &self,
1062        project: ProjectRef<'_>,
1063        f: &crate::function::Function,
1064    ) -> Result<(), DeployError> {
1065        let bytes = serde_json::to_vec(f).map_err(|e| DeployError::Serde(e.to_string()))?;
1066        self.kv
1067            .put(
1068                &crate::function::keys::meta(project.as_str(), &f.name),
1069                bytes,
1070            )
1071            .await?;
1072        Ok(())
1073    }
1074
1075    /// Load a stored function record, if any.
1076    pub async fn get_function(
1077        &self,
1078        project: ProjectRef<'_>,
1079        name: &str,
1080    ) -> Result<Option<crate::function::Function>, DeployError> {
1081        match self
1082            .kv
1083            .get(&crate::function::keys::meta(project.as_str(), name))
1084            .await?
1085        {
1086            Some(bytes) => Ok(Some(
1087                serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
1088            )),
1089            None => Ok(None),
1090        }
1091    }
1092
1093    /// List all stored (top-level) functions in `project`.
1094    ///
1095    /// Only the `…/functions/<name>` *meta* keys are function records; the
1096    /// `…/functions/<name>/{versions,alias,triggers,invocations,idem}/…` sub-keys
1097    /// are skipped by requiring the suffix to hold no further `/`.
1098    pub async fn list_stored_functions(
1099        &self,
1100        project: ProjectRef<'_>,
1101    ) -> Result<Vec<crate::function::Function>, DeployError> {
1102        let prefix = crate::function::keys::functions_prefix(project.as_str());
1103        let mut out = Vec::new();
1104        for key in self.kv.list_prefix(&prefix).await? {
1105            if key[prefix.len()..].contains('/') {
1106                continue;
1107            }
1108            if let Some(bytes) = self.kv.get(&key).await? {
1109                if let Ok(f) = serde_json::from_slice(&bytes) {
1110                    out.push(f);
1111                }
1112            }
1113        }
1114        Ok(out)
1115    }
1116
1117    /// Delete a stored function. Returns whether it existed. The component blobs
1118    /// are content-addressed + shared, so they are left to `prune`.
1119    pub async fn delete_function(
1120        &self,
1121        project: ProjectRef<'_>,
1122        name: &str,
1123    ) -> Result<bool, DeployError> {
1124        let meta = crate::function::keys::meta(project.as_str(), name);
1125        let existed = self.kv.get(&meta).await?.is_some();
1126        // Sweep the function's WHOLE subtree — versions / alias / triggers /
1127        // invocations (which also hold idempotency replays) — plus its separate
1128        // metering record, not just the `meta` key. The trailing `/` scopes the prefix
1129        // to THIS function, so `foo/` never matches a sibling `foobar/…`. Otherwise a
1130        // deleted function leaves orphaned keys under `project/<proj>/` that both look
1131        // "still registered" and keep delete_project at 409 (see delete_project).
1132        for key in self.kv.list_prefix(&format!("{meta}/")).await? {
1133            self.kv.delete(&key).await?;
1134        }
1135        self.kv.delete(&meta).await?;
1136        self.kv
1137            .delete(&crate::function::keys::metering(project.as_str(), name))
1138            .await?;
1139        Ok(existed)
1140    }
1141
1142    // ---- function triggers (FA-3 scheduled / FA-5 event sources) ------------
1143
1144    /// Persist (create or replace) a stored trigger on a function.
1145    pub async fn put_trigger(
1146        &self,
1147        project: ProjectRef<'_>,
1148        function: &str,
1149        trigger: &crate::function::FunctionTrigger,
1150    ) -> Result<(), DeployError> {
1151        let bytes = serde_json::to_vec(trigger).map_err(|e| DeployError::Serde(e.to_string()))?;
1152        self.kv
1153            .put(
1154                &crate::function::keys::trigger(project.as_str(), function, &trigger.id),
1155                bytes,
1156            )
1157            .await?;
1158        Ok(())
1159    }
1160
1161    /// Load one stored trigger, if any.
1162    pub async fn get_trigger(
1163        &self,
1164        project: ProjectRef<'_>,
1165        function: &str,
1166        id: &str,
1167    ) -> Result<Option<crate::function::FunctionTrigger>, DeployError> {
1168        match self
1169            .kv
1170            .get(&crate::function::keys::trigger(
1171                project.as_str(),
1172                function,
1173                id,
1174            ))
1175            .await?
1176        {
1177            Some(bytes) => Ok(Some(
1178                serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
1179            )),
1180            None => Ok(None),
1181        }
1182    }
1183
1184    /// List a function's stored triggers.
1185    pub async fn list_triggers(
1186        &self,
1187        project: ProjectRef<'_>,
1188        function: &str,
1189    ) -> Result<Vec<crate::function::FunctionTrigger>, DeployError> {
1190        let prefix = crate::function::keys::triggers_prefix(project.as_str(), function);
1191        let mut out = Vec::new();
1192        for key in self.kv.list_prefix(&prefix).await? {
1193            if let Some(bytes) = self.kv.get(&key).await? {
1194                if let Ok(t) = serde_json::from_slice(&bytes) {
1195                    out.push(t);
1196                }
1197            }
1198        }
1199        Ok(out)
1200    }
1201
1202    /// Delete a stored trigger. Returns whether it existed.
1203    pub async fn delete_trigger(
1204        &self,
1205        project: ProjectRef<'_>,
1206        function: &str,
1207        id: &str,
1208    ) -> Result<bool, DeployError> {
1209        let key = crate::function::keys::trigger(project.as_str(), function, id);
1210        let existed = self.kv.get(&key).await?.is_some();
1211        self.kv.delete(&key).await?;
1212        Ok(existed)
1213    }
1214
1215    // ---- function invocations (FA-3) ---------------------------------------
1216
1217    /// Persist (create or update) an invocation record.
1218    pub async fn put_invocation(
1219        &self,
1220        project: ProjectRef<'_>,
1221        inv: &crate::function::Invocation,
1222    ) -> Result<(), DeployError> {
1223        let bytes = serde_json::to_vec(inv).map_err(|e| DeployError::Serde(e.to_string()))?;
1224        self.kv
1225            .put(
1226                &crate::function::keys::invocation(project.as_str(), &inv.function, &inv.id),
1227                bytes,
1228            )
1229            .await?;
1230        Ok(())
1231    }
1232
1233    /// Load one invocation record, if any.
1234    pub async fn get_invocation(
1235        &self,
1236        project: ProjectRef<'_>,
1237        function: &str,
1238        id: &str,
1239    ) -> Result<Option<crate::function::Invocation>, DeployError> {
1240        match self
1241            .kv
1242            .get(&crate::function::keys::invocation(
1243                project.as_str(),
1244                function,
1245                id,
1246            ))
1247            .await?
1248        {
1249            Some(bytes) => Ok(Some(
1250                serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
1251            )),
1252            None => Ok(None),
1253        }
1254    }
1255
1256    /// List a function's invocation records (queue scan / poll listing).
1257    pub async fn list_invocations(
1258        &self,
1259        project: ProjectRef<'_>,
1260        function: &str,
1261    ) -> Result<Vec<crate::function::Invocation>, DeployError> {
1262        let prefix = crate::function::keys::invocations_prefix(project.as_str(), function);
1263        let mut out = Vec::new();
1264        for key in self.kv.list_prefix(&prefix).await? {
1265            if let Some(bytes) = self.kv.get(&key).await? {
1266                if let Ok(inv) = serde_json::from_slice(&bytes) {
1267                    out.push(inv);
1268                }
1269            }
1270        }
1271        Ok(out)
1272    }
1273
1274    /// Bind an idempotency key to an invocation id (the dedup pointer). The value
1275    /// is the raw invocation id.
1276    pub async fn put_idempotency(
1277        &self,
1278        project: ProjectRef<'_>,
1279        function: &str,
1280        key: &str,
1281        invocation_id: &str,
1282    ) -> Result<(), DeployError> {
1283        self.kv
1284            .put(
1285                &crate::function::keys::idempotency(project.as_str(), function, key),
1286                invocation_id.as_bytes().to_vec(),
1287            )
1288            .await?;
1289        Ok(())
1290    }
1291
1292    /// Resolve an idempotency key to its invocation id, if one was recorded.
1293    pub async fn get_idempotency(
1294        &self,
1295        project: ProjectRef<'_>,
1296        function: &str,
1297        key: &str,
1298    ) -> Result<Option<String>, DeployError> {
1299        match self
1300            .kv
1301            .get(&crate::function::keys::idempotency(
1302                project.as_str(),
1303                function,
1304                key,
1305            ))
1306            .await?
1307        {
1308            Some(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
1309            None => Ok(None),
1310        }
1311    }
1312
1313    // ---- function metering (FA-4) ------------------------------------------
1314
1315    /// The usage aggregate for a function, if any has been recorded.
1316    pub async fn get_metering(
1317        &self,
1318        project: ProjectRef<'_>,
1319        function: &str,
1320    ) -> Result<Option<crate::function::Metering>, DeployError> {
1321        match self
1322            .kv
1323            .get(&crate::function::keys::metering(project.as_str(), function))
1324            .await?
1325        {
1326            Some(bytes) => Ok(Some(
1327                serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
1328            )),
1329            None => Ok(None),
1330        }
1331    }
1332
1333    /// Persist a function's usage aggregate.
1334    pub async fn put_metering(
1335        &self,
1336        project: ProjectRef<'_>,
1337        metering: &crate::function::Metering,
1338    ) -> Result<(), DeployError> {
1339        let bytes = serde_json::to_vec(metering).map_err(|e| DeployError::Serde(e.to_string()))?;
1340        self.kv
1341            .put(
1342                &crate::function::keys::metering(project.as_str(), &metering.function),
1343                bytes,
1344            )
1345            .await?;
1346        Ok(())
1347    }
1348
1349    /// List every function's usage aggregate in `project` (the `functions usage`
1350    /// fan-out).
1351    pub async fn list_metering(
1352        &self,
1353        project: ProjectRef<'_>,
1354    ) -> Result<Vec<crate::function::Metering>, DeployError> {
1355        let prefix = crate::function::keys::metering_prefix(project.as_str());
1356        let mut out = Vec::new();
1357        for key in self.kv.list_prefix(&prefix).await? {
1358            if let Some(bytes) = self.kv.get(&key).await? {
1359                if let Ok(m) = serde_json::from_slice(&bytes) {
1360                    out.push(m);
1361                }
1362            }
1363        }
1364        Ok(out)
1365    }
1366
1367    // ---- blob-change notification ledger (FA-5b2) --------------------------
1368
1369    /// Record the cloud notification pipeline provisioned for a function's
1370    /// blob-change trigger, so it can be retracted later.
1371    pub async fn put_managed_notification(
1372        &self,
1373        project: ProjectRef<'_>,
1374        record: &crate::blob_notify::ManagedNotification,
1375    ) -> Result<(), DeployError> {
1376        let bytes = record
1377            .to_json()
1378            .map_err(|e| DeployError::Serde(e.to_string()))?;
1379        self.kv
1380            .put(
1381                &crate::blob_notify::blobnotify_key(
1382                    project.as_str(),
1383                    &record.function,
1384                    &record.prefix,
1385                ),
1386                bytes,
1387            )
1388            .await?;
1389        Ok(())
1390    }
1391
1392    /// The notification ledger entry for `(function, prefix)`, if any.
1393    pub async fn get_managed_notification(
1394        &self,
1395        project: ProjectRef<'_>,
1396        function: &str,
1397        prefix: &str,
1398    ) -> Result<Option<crate::blob_notify::ManagedNotification>, DeployError> {
1399        match self
1400            .kv
1401            .get(&crate::blob_notify::blobnotify_key(
1402                project.as_str(),
1403                function,
1404                prefix,
1405            ))
1406            .await?
1407        {
1408            Some(bytes) => Ok(Some(
1409                crate::blob_notify::ManagedNotification::from_json(&bytes)
1410                    .map_err(|e| DeployError::Serde(e.to_string()))?,
1411            )),
1412            None => Ok(None),
1413        }
1414    }
1415
1416    /// All notification ledger entries for a function.
1417    pub async fn list_managed_notifications(
1418        &self,
1419        project: ProjectRef<'_>,
1420        function: &str,
1421    ) -> Result<Vec<crate::blob_notify::ManagedNotification>, DeployError> {
1422        let prefix = crate::blob_notify::blobnotify_function_prefix(project.as_str(), function);
1423        let mut out = Vec::new();
1424        for key in self.kv.list_prefix(&prefix).await? {
1425            if let Some(bytes) = self.kv.get(&key).await? {
1426                if let Ok(record) = crate::blob_notify::ManagedNotification::from_json(&bytes) {
1427                    out.push(record);
1428                }
1429            }
1430        }
1431        Ok(out)
1432    }
1433
1434    /// Drop a notification ledger entry (after its resources are retracted).
1435    pub async fn remove_managed_notification(
1436        &self,
1437        project: ProjectRef<'_>,
1438        function: &str,
1439        prefix: &str,
1440    ) -> Result<(), DeployError> {
1441        self.kv
1442            .delete(&crate::blob_notify::blobnotify_key(
1443                project.as_str(),
1444                function,
1445                prefix,
1446            ))
1447            .await?;
1448        Ok(())
1449    }
1450
1451    // ---- workflows (FA-6) --------------------------------------------------
1452
1453    /// Persist a workflow definition.
1454    pub async fn put_workflow(
1455        &self,
1456        project: ProjectRef<'_>,
1457        workflow: &crate::workflow::Workflow,
1458    ) -> Result<(), DeployError> {
1459        let bytes = serde_json::to_vec(workflow).map_err(|e| DeployError::Serde(e.to_string()))?;
1460        self.kv
1461            .put(
1462                &crate::workflow::keys::definition(project.as_str(), &workflow.name),
1463                bytes,
1464            )
1465            .await?;
1466        Ok(())
1467    }
1468
1469    /// Load a workflow definition, if any.
1470    pub async fn get_workflow(
1471        &self,
1472        project: ProjectRef<'_>,
1473        name: &str,
1474    ) -> Result<Option<crate::workflow::Workflow>, DeployError> {
1475        match self
1476            .kv
1477            .get(&crate::workflow::keys::definition(project.as_str(), name))
1478            .await?
1479        {
1480            Some(bytes) => Ok(Some(
1481                serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
1482            )),
1483            None => Ok(None),
1484        }
1485    }
1486
1487    /// List all workflow definitions in `project` (skips the
1488    /// `…/workflows/<name>/runs/…` sub-keys by requiring the suffix to hold no
1489    /// further `/`).
1490    pub async fn list_workflows(
1491        &self,
1492        project: ProjectRef<'_>,
1493    ) -> Result<Vec<crate::workflow::Workflow>, DeployError> {
1494        let prefix = crate::workflow::keys::definitions_prefix(project.as_str());
1495        let mut out = Vec::new();
1496        for key in self.kv.list_prefix(&prefix).await? {
1497            if key[prefix.len()..].contains('/') {
1498                continue;
1499            }
1500            if let Some(bytes) = self.kv.get(&key).await? {
1501                if let Ok(w) = serde_json::from_slice(&bytes) {
1502                    out.push(w);
1503                }
1504            }
1505        }
1506        Ok(out)
1507    }
1508
1509    /// Delete a workflow definition. Returns whether it existed. Runs are left in
1510    /// place (terminal history); `prune` removes them.
1511    pub async fn delete_workflow(
1512        &self,
1513        project: ProjectRef<'_>,
1514        name: &str,
1515    ) -> Result<bool, DeployError> {
1516        let key = crate::workflow::keys::definition(project.as_str(), name);
1517        let existed = self.kv.get(&key).await?.is_some();
1518        self.kv.delete(&key).await?;
1519        Ok(existed)
1520    }
1521
1522    /// Persist (create or update) a workflow run.
1523    pub async fn put_workflow_run(
1524        &self,
1525        project: ProjectRef<'_>,
1526        run: &crate::workflow::WorkflowRun,
1527    ) -> Result<(), DeployError> {
1528        let bytes = serde_json::to_vec(run).map_err(|e| DeployError::Serde(e.to_string()))?;
1529        self.kv
1530            .put(
1531                &crate::workflow::keys::run(project.as_str(), &run.workflow, &run.id),
1532                bytes,
1533            )
1534            .await?;
1535        Ok(())
1536    }
1537
1538    /// Load one workflow run, if any.
1539    pub async fn get_workflow_run(
1540        &self,
1541        project: ProjectRef<'_>,
1542        workflow: &str,
1543        id: &str,
1544    ) -> Result<Option<crate::workflow::WorkflowRun>, DeployError> {
1545        match self
1546            .kv
1547            .get(&crate::workflow::keys::run(project.as_str(), workflow, id))
1548            .await?
1549        {
1550            Some(bytes) => Ok(Some(
1551                serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
1552            )),
1553            None => Ok(None),
1554        }
1555    }
1556
1557    /// List a workflow's runs (the executor drain scan / poll listing).
1558    pub async fn list_workflow_runs(
1559        &self,
1560        project: ProjectRef<'_>,
1561        workflow: &str,
1562    ) -> Result<Vec<crate::workflow::WorkflowRun>, DeployError> {
1563        let prefix = crate::workflow::keys::runs_prefix(project.as_str(), workflow);
1564        let mut out = Vec::new();
1565        for key in self.kv.list_prefix(&prefix).await? {
1566            if let Some(bytes) = self.kv.get(&key).await? {
1567                if let Ok(run) = serde_json::from_slice(&bytes) {
1568                    out.push(run);
1569                }
1570            }
1571        }
1572        Ok(out)
1573    }
1574
1575    /// The ownership-verification challenge for `(site, host)`, if one exists.
1576    pub async fn get_domain_verification(
1577        &self,
1578        project: ProjectRef<'_>,
1579        site: &SiteName,
1580        host: &str,
1581    ) -> Result<Option<DomainVerification>, DeployError> {
1582        match self
1583            .kv
1584            .get(&keys::domain_verification(project, site.as_str(), host))
1585            .await?
1586        {
1587            Some(bytes) => Ok(Some(DomainVerification::from_json(&bytes)?)),
1588            None => Ok(None),
1589        }
1590    }
1591
1592    /// All ownership challenges for `site` (pending and verified), by host.
1593    pub async fn list_domain_verifications(
1594        &self,
1595        project: ProjectRef<'_>,
1596        site: &SiteName,
1597    ) -> Result<Vec<DomainVerification>, DeployError> {
1598        let prefix = keys::domain_verification_prefix(project, site.as_str());
1599        let mut out = Vec::new();
1600        for key in self.kv.list_prefix(&prefix).await? {
1601            if let Some(bytes) = self.kv.get(&key).await? {
1602                out.push(DomainVerification::from_json(&bytes)?);
1603            }
1604        }
1605        out.sort_by(|a, b| a.host.cmp(&b.host));
1606        Ok(out)
1607    }
1608
1609    /// Every ownership challenge across **all** projects, each paired with its
1610    /// owning `(project, site)` — the enumeration behind the auto-complete
1611    /// reconcile loop. Fans out over [`discover_projects`](Self::discover_projects)
1612    /// and scans each project's `domainverify/` keyspace, so a challenge started
1613    /// before a site's first publish (its site isn't in
1614    /// [`all_sites`](Self::all_sites) yet) is still found and can self-heal.
1615    pub async fn list_all_domain_verifications(
1616        &self,
1617    ) -> Result<Vec<(String, String, DomainVerification)>, DeployError> {
1618        let mut out = Vec::new();
1619        for project in self.discover_projects().await? {
1620            let pref = ProjectRef::new(&project);
1621            let scan = keys::domain_verification_project_prefix(pref);
1622            for key in self.kv.list_prefix(&scan).await? {
1623                // Key shape: `project/<proj>/domainverify/<site>/<host>`; the site
1624                // is the first segment after the scan prefix (no `/` in a site name).
1625                let Some((site, _host)) = key
1626                    .strip_prefix(&scan)
1627                    .and_then(|rest| rest.split_once('/'))
1628                else {
1629                    continue;
1630                };
1631                if let Some(bytes) = self.kv.get(&key).await? {
1632                    out.push((
1633                        project.clone(),
1634                        site.to_string(),
1635                        DomainVerification::from_json(&bytes)?,
1636                    ));
1637                }
1638            }
1639        }
1640        Ok(out)
1641    }
1642
1643    /// Find a **pending HTTP** ownership challenge matching `(host, token)`
1644    /// across every project/site — the lookup behind the self-serve edge route
1645    /// `/.well-known/boatramp-domain-verification/<token>`. Matches on the
1646    /// normalized host, the HTTP method, an exact token match, and a non-expired
1647    /// challenge (`now_unix` gates the TTL), so a host pointed at this server can
1648    /// prove ownership before it is attached and deployed — closing the
1649    /// verify-before-attach chicken-and-egg. Returns the challenge so the caller
1650    /// can echo its token back.
1651    pub async fn find_pending_http_challenge(
1652        &self,
1653        host: &str,
1654        token: &str,
1655        now_unix: u64,
1656    ) -> Result<Option<DomainVerification>, DeployError> {
1657        let host = crate::domain_verify::normalize_host(host);
1658        // O(1): the `(host, token)` index names the owning `(project, site)`
1659        // directly (tolerant of the legacy bare-site value). Then load and **fully
1660        // re-validate** the challenge — so a stale index entry (left by a method
1661        // change / new token) can never serve the wrong thing.
1662        let Some(owner_bytes) = self
1663            .kv
1664            .get(&keys::http_challenge_index(&host, token))
1665            .await?
1666        else {
1667            return Ok(None);
1668        };
1669        let owner = DomainOwner::from_bytes(&owner_bytes);
1670        let site = SiteName::new(owner.site);
1671        let Some(v) = self
1672            .get_domain_verification(ProjectRef::new(&owner.project), &site, &host)
1673            .await?
1674        else {
1675            return Ok(None);
1676        };
1677        if v.method == VerificationMethod::Http
1678            && v.host == host
1679            && v.matches(token)
1680            && !v.is_expired(now_unix)
1681        {
1682            Ok(Some(v))
1683        } else {
1684            Ok(None)
1685        }
1686    }
1687
1688    async fn put_domain_verification(
1689        &self,
1690        project: ProjectRef<'_>,
1691        site: &SiteName,
1692        verification: &DomainVerification,
1693    ) -> Result<(), DeployError> {
1694        let mut ops = vec![WriteOp::Put(
1695            keys::domain_verification(project, site.as_str(), &verification.host),
1696            verification.to_json()?,
1697        )];
1698        // Maintain the self-serve `(host, token)` index for HTTP challenges. Its
1699        // value carries the owning `(project, site)` so the self-serve edge route
1700        // can resolve the project-scoped challenge. A stale entry left by a later
1701        // method/token change is harmless — the lookup re-validates the loaded
1702        // challenge — so no delete-on-replace is needed here.
1703        if verification.method == VerificationMethod::Http {
1704            ops.push(WriteOp::Put(
1705                keys::http_challenge_index(&verification.host, &verification.token),
1706                DomainOwner::new(project.as_str(), site.as_str()).to_bytes(),
1707            ));
1708        }
1709        self.kv.write_batch(ops).await?;
1710        Ok(())
1711    }
1712
1713    // ---- managed-DNS ledger (records boatramp pointed at the server) ----------
1714
1715    /// The managed-DNS ledger for `(site, host)`, if boatramp has pointed it.
1716    pub async fn get_managed_dns(
1717        &self,
1718        project: ProjectRef<'_>,
1719        site: &SiteName,
1720        host: &str,
1721    ) -> Result<Option<crate::dns_managed::ManagedDns>, DeployError> {
1722        match self
1723            .kv
1724            .get(&crate::dns_managed::dnsmanaged_key(
1725                project.as_str(),
1726                site.as_str(),
1727                host,
1728            ))
1729            .await?
1730        {
1731            Some(bytes) => Ok(Some(crate::dns_managed::ManagedDns::from_json(&bytes)?)),
1732            None => Ok(None),
1733        }
1734    }
1735
1736    /// Record (create/replace) the managed-DNS ledger entry for a host.
1737    pub async fn set_managed_dns(
1738        &self,
1739        project: ProjectRef<'_>,
1740        site: &SiteName,
1741        ledger: &crate::dns_managed::ManagedDns,
1742    ) -> Result<(), DeployError> {
1743        self.kv
1744            .put(
1745                &crate::dns_managed::dnsmanaged_key(project.as_str(), site.as_str(), &ledger.host),
1746                ledger.to_json()?,
1747            )
1748            .await?;
1749        Ok(())
1750    }
1751
1752    /// Drop a host's managed-DNS ledger entry (after its records are retracted).
1753    pub async fn remove_managed_dns(
1754        &self,
1755        project: ProjectRef<'_>,
1756        site: &SiteName,
1757        host: &str,
1758    ) -> Result<(), DeployError> {
1759        self.kv
1760            .delete(&crate::dns_managed::dnsmanaged_key(
1761                project.as_str(),
1762                site.as_str(),
1763                host,
1764            ))
1765            .await?;
1766        Ok(())
1767    }
1768
1769    /// All managed-DNS ledger entries for `site` (the reconcile sweep reads these
1770    /// to retract records whose host is no longer attached).
1771    pub async fn list_managed_dns(
1772        &self,
1773        project: ProjectRef<'_>,
1774        site: &SiteName,
1775    ) -> Result<Vec<crate::dns_managed::ManagedDns>, DeployError> {
1776        let prefix = crate::dns_managed::dnsmanaged_site_prefix(project.as_str(), site.as_str());
1777        let mut out = Vec::new();
1778        for key in self.kv.list_prefix(&prefix).await? {
1779            if let Some(bytes) = self.kv.get(&key).await? {
1780                out.push(crate::dns_managed::ManagedDns::from_json(&bytes)?);
1781            }
1782        }
1783        out.sort_by(|a, b| a.host.cmp(&b.host));
1784        Ok(out)
1785    }
1786
1787    /// Start (or restart) an ownership challenge for `(site, host)`.
1788    ///
1789    /// Returns the existing challenge if one is already pending under the same
1790    /// method (so re-running `domain add` shows the same token instead of
1791    /// invalidating an in-progress setup); otherwise mints a fresh one. A
1792    /// challenge that's already `verified` is returned untouched.
1793    pub async fn start_domain_verification(
1794        &self,
1795        project: ProjectRef<'_>,
1796        site: &SiteName,
1797        host: &str,
1798        method: VerificationMethod,
1799        now_unix: u64,
1800    ) -> Result<DomainVerification, DeployError> {
1801        if let Some(existing) = self.get_domain_verification(project, site, host).await? {
1802            if existing.verified || existing.method == method {
1803                return Ok(existing);
1804            }
1805        }
1806        // Cap the pending set per site. An unbounded number of pending challenges
1807        // would let a tenant seed many hosts that the auto-complete reconcile loop
1808        // then re-probes every tick (a low-and-slow outbound-probe amplifier to
1809        // arbitrary public hosts). A generous ceiling bounds both storage and the
1810        // per-tick fan-out without limiting real use. (Re-running `domain add` on an
1811        // existing host returns early above, so this only gates genuinely-new hosts.)
1812        const MAX_PENDING_VERIFICATIONS_PER_SITE: usize = 64;
1813        let pending = self
1814            .list_domain_verifications(project, site)
1815            .await?
1816            .into_iter()
1817            .filter(|v| !v.verified && !v.is_expired(now_unix))
1818            .count();
1819        if pending >= MAX_PENDING_VERIFICATIONS_PER_SITE {
1820            return Err(DeployError::Conflict(format!(
1821                "too many pending domain verifications for site {site} \
1822                 (max {MAX_PENDING_VERIFICATIONS_PER_SITE}); verify or remove some first"
1823            )));
1824        }
1825        let verification = DomainVerification::new(host, method, now_unix);
1826        self.put_domain_verification(project, site, &verification)
1827            .await?;
1828        Ok(verification)
1829    }
1830
1831    /// Whether `(site, host)` has a confirmed ownership challenge.
1832    pub async fn is_domain_verified(
1833        &self,
1834        project: ProjectRef<'_>,
1835        site: &SiteName,
1836        host: &str,
1837    ) -> Result<bool, DeployError> {
1838        Ok(self
1839            .get_domain_verification(project, site, host)
1840            .await?
1841            .is_some_and(|v| v.verified))
1842    }
1843
1844    /// Mark `(site, host)`'s challenge verified and persist it. Errors if no
1845    /// challenge has been started.
1846    pub async fn mark_domain_verified(
1847        &self,
1848        project: ProjectRef<'_>,
1849        site: &SiteName,
1850        host: &str,
1851    ) -> Result<DomainVerification, DeployError> {
1852        let mut verification = self
1853            .get_domain_verification(project, site, host)
1854            .await?
1855            .ok_or_else(|| {
1856                DeployError::NotFound(format!("no verification challenge for {host}"))
1857            })?;
1858        verification.verified = true;
1859        self.put_domain_verification(project, site, &verification)
1860            .await?;
1861        Ok(verification)
1862    }
1863
1864    /// Drop the verification record for `(site, host)` (when detaching a host).
1865    /// Returns whether one existed.
1866    pub async fn remove_domain_verification(
1867        &self,
1868        project: ProjectRef<'_>,
1869        site: &SiteName,
1870        host: &str,
1871    ) -> Result<bool, DeployError> {
1872        let Some(v) = self.get_domain_verification(project, site, host).await? else {
1873            return Ok(false);
1874        };
1875        let mut ops = vec![WriteOp::Delete(keys::domain_verification(
1876            project,
1877            site.as_str(),
1878            host,
1879        ))];
1880        if v.method == VerificationMethod::Http {
1881            ops.push(WriteOp::Delete(keys::http_challenge_index(
1882                &v.host, &v.token,
1883            )));
1884        }
1885        self.kv.write_batch(ops).await?;
1886        Ok(true)
1887    }
1888
1889    /// Attach a verified `host` to the site's [`SiteConfig`] so it routes by
1890    /// `Host` and becomes eligible for ACME. The host's *kind* is inferred:
1891    /// a `*.`-prefixed host is a wildcard; otherwise it becomes the primary if
1892    /// the site has none, else an alias.
1893    ///
1894    /// Refuses an unverified host — this is the server-enforced gate that keeps
1895    /// unowned domains out of routing and out of cert issuance. (The wildcard /
1896    /// primary base name is verified; see [`normalize_host`].)
1897    ///
1898    /// [`normalize_host`]: crate::domain_verify::normalize_host
1899    pub async fn attach_verified_domain(
1900        &self,
1901        project: ProjectRef<'_>,
1902        site: &SiteName,
1903        host: &str,
1904    ) -> Result<SiteConfig, DeployError> {
1905        if !self.is_domain_verified(project, site, host).await? {
1906            return Err(DeployError::NotFound(format!(
1907                "{host} is not verified for {site}; run domain verification first"
1908            )));
1909        }
1910        // A wildcard needs the stronger DNS proof — an HTTP token at the base host
1911        // proves control of one name, not the whole subtree (ACME requires DNS-01
1912        // for a wildcard cert for the same reason).
1913        if host.trim_start().starts_with("*.")
1914            && self
1915                .get_domain_verification(project, site, host)
1916                .await?
1917                .map(|v| v.method)
1918                != Some(VerificationMethod::Dns)
1919        {
1920            return Err(DeployError::Conflict(format!(
1921                "wildcard {host} must be verified via DNS \
1922                 (an HTTP token proves only the base host, not the subtree)"
1923            )));
1924        }
1925        // Hold the claim lock across the whole read-modify-write so a concurrent
1926        // attach (to this site or another) can't interleave between our read and
1927        // the index write. `set_site_config_locked` runs under the same lock.
1928        let _claim = self.domain_claim_lock.lock().await;
1929        let mut config = self
1930            .get_site_config(project, site.as_str())
1931            .await?
1932            .unwrap_or_default();
1933        let domains = &mut config.domains;
1934        if let Some(suffix) = host.strip_prefix("*.") {
1935            let wildcard = format!("*.{}", suffix.trim_end_matches('.').to_ascii_lowercase());
1936            if !domains.wildcards.contains(&wildcard) {
1937                domains.wildcards.push(wildcard);
1938            }
1939        } else {
1940            let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
1941            if domains.primary.is_none() {
1942                domains.primary = Some(host);
1943            } else if domains.primary.as_deref() != Some(host.as_str())
1944                && !domains.aliases.contains(&host)
1945            {
1946                domains.aliases.push(host);
1947            }
1948        }
1949        self.set_site_config_locked(project, site.as_str(), &config)
1950            .await?;
1951        Ok(config)
1952    }
1953
1954    /// Point a named alias (`staging`, `preview-pr-42`, …) at a deployment id.
1955    ///
1956    /// Like [`activate`](Self::activate), this refuses a deployment whose blobs
1957    /// are not all present, so an alias never resolves to an incomplete deploy.
1958    /// Aliased deployments are retention-protected from garbage collection.
1959    pub async fn set_alias(
1960        &self,
1961        project: ProjectRef<'_>,
1962        site: &str,
1963        name: &str,
1964        id: &str,
1965    ) -> Result<(), DeployError> {
1966        let manifest = self
1967            .get_manifest(id)
1968            .await?
1969            .ok_or_else(|| DeployError::NotFound(format!("deployment {id}")))?;
1970        let missing = self.missing_blobs(&manifest).await?;
1971        if !missing.is_empty() {
1972            return Err(DeployError::Incomplete(missing));
1973        }
1974        self.kv
1975            .put(&keys::alias(project, site, name), id.as_bytes().to_vec())
1976            .await?;
1977        Ok(())
1978    }
1979
1980    /// Resolve a named alias to its deployment id, if set.
1981    pub async fn get_alias(
1982        &self,
1983        project: ProjectRef<'_>,
1984        site: &str,
1985        name: &str,
1986    ) -> Result<Option<String>, DeployError> {
1987        match self.kv.get(&keys::alias(project, site, name)).await? {
1988            Some(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
1989            None => Ok(None),
1990        }
1991    }
1992
1993    /// Remove a named alias; returns whether one existed.
1994    pub async fn remove_alias(
1995        &self,
1996        project: ProjectRef<'_>,
1997        site: &str,
1998        name: &str,
1999    ) -> Result<bool, DeployError> {
2000        let key = keys::alias(project, site, name);
2001        let existed = self.kv.get(&key).await?.is_some();
2002        if existed {
2003            self.kv.delete(&key).await?;
2004        }
2005        Ok(existed)
2006    }
2007
2008    /// All of a site's named aliases as `name → deployment id`, sorted by name.
2009    pub async fn list_aliases(
2010        &self,
2011        project: ProjectRef<'_>,
2012        site: &str,
2013    ) -> Result<BTreeMap<String, String>, DeployError> {
2014        let prefix = keys::alias_prefix(project, site);
2015        let mut out = BTreeMap::new();
2016        for key in self.kv.list_prefix(&prefix).await? {
2017            if let Some(bytes) = self.kv.get(&key).await? {
2018                let name = key.strip_prefix(&prefix).unwrap_or(&key).to_string();
2019                out.insert(name, String::from_utf8_lossy(&bytes).into_owned());
2020            }
2021        }
2022        Ok(out)
2023    }
2024
2025    /// Store metadata for an issued token (`authz/tokens/<id>`). The
2026    /// token itself is never stored — only this record, for `token ls`.
2027    /// Minting needs the root private key, so it happens in the
2028    /// caller (the API route / CLI), which then records the metadata here.
2029    pub async fn put_token_meta(&self, meta: &crate::authz::TokenMeta) -> Result<(), DeployError> {
2030        self.kv
2031            .put(
2032                &crate::authz::token_meta_key(&meta.revocation_id),
2033                serde_json::to_vec(meta)?,
2034            )
2035            .await?;
2036        Ok(())
2037    }
2038
2039    /// List metadata for all issued, non-revoked tokens.
2040    pub async fn list_token_meta(&self) -> Result<Vec<crate::authz::TokenMeta>, DeployError> {
2041        let mut out = Vec::new();
2042        for key in self.kv.list_prefix(crate::authz::TOKEN_META_PREFIX).await? {
2043            if let Some(bytes) = self.kv.get(&key).await? {
2044                if let Ok(meta) = serde_json::from_slice::<crate::authz::TokenMeta>(&bytes) {
2045                    out.push(meta);
2046                }
2047            }
2048        }
2049        Ok(out)
2050    }
2051
2052    /// Revoke an issued token by its revocation id (or a unique id prefix):
2053    /// write the `authz/revoked/<id>` marker and drop its metadata. Returns
2054    /// whether a matching token was found. The marker makes every node deny the
2055    /// token (and its attenuations) on the next request.
2056    pub async fn revoke_token(&self, id_or_prefix: &str) -> Result<bool, DeployError> {
2057        let ids: Vec<String> = self
2058            .list_token_meta()
2059            .await?
2060            .into_iter()
2061            .map(|m| m.revocation_id)
2062            .collect();
2063        let matches: Vec<&String> = ids
2064            .iter()
2065            .filter(|id| id.starts_with(id_or_prefix))
2066            .collect();
2067        if let [id] = matches.as_slice() {
2068            self.kv
2069                .put(&crate::authz::revoked_key(id), Vec::new())
2070                .await?;
2071            self.kv.delete(&crate::authz::token_meta_key(id)).await?;
2072            Ok(true)
2073        } else {
2074            Ok(false)
2075        }
2076    }
2077
2078    /// Whether a first-token bootstrap secret (identified by its SHA-256 hex) has
2079    /// already been redeemed. The marker persists, so a spent secret stays spent
2080    /// across restarts; rotating the secret yields a fresh hash that re-enables
2081    /// bootstrap (the recovery path).
2082    pub async fn bootstrap_consumed(&self, secret_hash: &str) -> Result<bool, DeployError> {
2083        Ok(self
2084            .kv
2085            .get(&crate::authz::bootstrap_key(secret_hash))
2086            .await?
2087            .is_some())
2088    }
2089
2090    /// Mark a bootstrap secret (by SHA-256 hex) consumed — single-use.
2091    pub async fn mark_bootstrap_consumed(&self, secret_hash: &str) -> Result<(), DeployError> {
2092        self.kv
2093            .put(&crate::authz::bootstrap_key(secret_hash), Vec::new())
2094            .await?;
2095        Ok(())
2096    }
2097
2098    /// Read the stored RBAC `AuthzPolicy` (`authz/policy`), or `None` when the
2099    /// built-in default is in effect.
2100    pub async fn get_authz_policy(&self) -> Result<Option<crate::authz::AuthzPolicy>, DeployError> {
2101        match self.kv.get(crate::authz::POLICY_KEY).await? {
2102            Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
2103            None => Ok(None),
2104        }
2105    }
2106
2107    /// Store the RBAC `AuthzPolicy`. The caller validates it first (the server
2108    /// route compiles it before storing); a write rides the existing cache
2109    /// invalidation so every node picks it up.
2110    pub async fn set_authz_policy(
2111        &self,
2112        policy: &crate::authz::AuthzPolicy,
2113    ) -> Result<(), DeployError> {
2114        self.kv
2115            .put(crate::authz::POLICY_KEY, serde_json::to_vec(policy)?)
2116            .await?;
2117        Ok(())
2118    }
2119
2120    /// Trust an additional **root anchor** (`auth rotate-root`): a `TokenPublicKey`
2121    /// (`alg:hex`) accepted alongside the configured primary root, for a
2122    /// make-before-break rotation. Replicated to every node through the control
2123    /// plane, so no per-node edit is needed.
2124    pub async fn add_root_anchor(&self, pubkey: &str) -> Result<(), DeployError> {
2125        self.kv
2126            .put(&crate::authz::root_anchor_key(pubkey), Vec::new())
2127            .await?;
2128        Ok(())
2129    }
2130
2131    /// Retire a previously-added root anchor (the old key, after propagation).
2132    pub async fn remove_root_anchor(&self, pubkey: &str) -> Result<(), DeployError> {
2133        self.kv
2134            .delete(&crate::authz::root_anchor_key(pubkey))
2135            .await?;
2136        Ok(())
2137    }
2138
2139    /// The currently-trusted extra root anchors (the `alg:hex` public keys).
2140    pub async fn list_root_anchors(&self) -> Result<Vec<String>, DeployError> {
2141        Ok(self
2142            .kv
2143            .list_prefix(crate::authz::ROOT_ANCHOR_PREFIX)
2144            .await?
2145            .iter()
2146            .filter_map(|k| {
2147                k.strip_prefix(crate::authz::ROOT_ANCHOR_PREFIX)
2148                    .map(String::from)
2149            })
2150            .collect())
2151    }
2152
2153    // ---- Dynamic daemon config --------------------------------------------
2154
2155    /// The `daemon/current` pointer key → the active generation hash.
2156    const DAEMON_CURRENT_KEY: &'static str = "daemon/current";
2157    /// The `daemon/history` key → JSON array of prior generation hashes (rollback).
2158    const DAEMON_HISTORY_KEY: &'static str = "daemon/history";
2159    /// How many prior generations the rollback ring retains.
2160    const DAEMON_HISTORY_MAX: usize = 20;
2161
2162    /// The active daemon-config **generation** (the `daemon/current` hash), if any.
2163    /// This is the value nodes report so an operator can confirm convergence.
2164    pub async fn daemon_config_generation(&self) -> Result<Option<String>, DeployError> {
2165        Ok(self
2166            .kv
2167            .get(Self::DAEMON_CURRENT_KEY)
2168            .await?
2169            .map(|b| String::from_utf8_lossy(&b).into_owned()))
2170    }
2171
2172    /// The active dynamic daemon config, if any (`None` = none set ⇒ the server
2173    /// runs on the pure file baseline).
2174    pub async fn get_daemon_config(
2175        &self,
2176    ) -> Result<Option<crate::daemon_config::DaemonConfig>, DeployError> {
2177        let Some(hash) = self.daemon_config_generation().await? else {
2178            return Ok(None);
2179        };
2180        match self.kv.get(&keys::daemon_config_blob(&hash)).await? {
2181            Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
2182            // Dangling pointer (body GC'd) reads as unset → baseline.
2183            None => Ok(None),
2184        }
2185    }
2186
2187    /// The rollback history (oldest → newest prior generation hashes; excludes the
2188    /// current generation).
2189    pub async fn daemon_config_history(&self) -> Result<Vec<String>, DeployError> {
2190        match self.kv.get(Self::DAEMON_HISTORY_KEY).await? {
2191            Some(bytes) => Ok(serde_json::from_slice(&bytes)?),
2192            None => Ok(Vec::new()),
2193        }
2194    }
2195
2196    /// Store a new daemon config: write the content-addressed body, push the
2197    /// current generation onto the bounded history, and flip the `daemon/current`
2198    /// pointer — all as one atomic batch. **The caller validates first** (the
2199    /// server route runs [`DaemonConfig::validate`](crate::daemon_config::DaemonConfig::validate)
2200    /// before this). Returns the new generation hash.
2201    pub async fn set_daemon_config(
2202        &self,
2203        config: &crate::daemon_config::DaemonConfig,
2204    ) -> Result<String, DeployError> {
2205        let body = serde_json::to_vec(config)?;
2206        let hash = sha256_hex(&body);
2207        let mut history = self.daemon_config_history().await?;
2208        if let Some(current) = self.daemon_config_generation().await? {
2209            if current != hash {
2210                history.push(current);
2211                if history.len() > Self::DAEMON_HISTORY_MAX {
2212                    let overflow = history.len() - Self::DAEMON_HISTORY_MAX;
2213                    history.drain(0..overflow);
2214                }
2215            }
2216        }
2217        let ops = vec![
2218            WriteOp::Put(keys::daemon_config_blob(&hash), body),
2219            WriteOp::Put(
2220                Self::DAEMON_HISTORY_KEY.to_string(),
2221                serde_json::to_vec(&history)?,
2222            ),
2223            WriteOp::Put(
2224                Self::DAEMON_CURRENT_KEY.to_string(),
2225                hash.clone().into_bytes(),
2226            ),
2227        ];
2228        self.kv.write_batch(ops).await?;
2229        Ok(hash)
2230    }
2231
2232    /// Roll back to the previous generation: pop the history and flip the pointer,
2233    /// atomically. Returns the hash rolled back to, or `None` if there is no
2234    /// history. Reverting past the last dynamic config falls back to the file
2235    /// baseline (which already booted successfully — the known-good floor).
2236    pub async fn rollback_daemon_config(&self) -> Result<Option<String>, DeployError> {
2237        let mut history = self.daemon_config_history().await?;
2238        let Some(prev) = history.pop() else {
2239            return Ok(None);
2240        };
2241        let ops = vec![
2242            WriteOp::Put(
2243                Self::DAEMON_HISTORY_KEY.to_string(),
2244                serde_json::to_vec(&history)?,
2245            ),
2246            WriteOp::Put(
2247                Self::DAEMON_CURRENT_KEY.to_string(),
2248                prev.clone().into_bytes(),
2249            ),
2250        ];
2251        self.kv.write_batch(ops).await?;
2252        Ok(Some(prev))
2253    }
2254
2255    // ---- Compute workloads ------------------------------------------------
2256
2257    /// Store an immutable, content-addressed [`ComputeSpec`](crate::compute::ComputeSpec)
2258    /// at `computever/<hash>` (idempotent), returning its hash.
2259    pub async fn put_compute_spec(
2260        &self,
2261        spec: &crate::compute::ComputeSpec,
2262    ) -> Result<String, DeployError> {
2263        let id = spec.id();
2264        self.kv
2265            .put(&crate::compute::spec_key(&id), serde_json::to_vec(spec)?)
2266            .await?;
2267        Ok(id)
2268    }
2269
2270    /// Read a compute spec by its content hash.
2271    pub async fn get_compute_spec(
2272        &self,
2273        hash: &str,
2274    ) -> Result<Option<crate::compute::ComputeSpec>, DeployError> {
2275        match self.kv.get(&crate::compute::spec_key(hash)).await? {
2276            Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
2277            None => Ok(None),
2278        }
2279    }
2280
2281    /// Set (replacing) a workload's desired state at
2282    /// `project/<proj>/compute/<name>`. Activation is this pointer write — atomic,
2283    /// like a deployment's `current`.
2284    pub async fn set_compute_workload(
2285        &self,
2286        project: ProjectRef<'_>,
2287        workload: &crate::compute::ComputeWorkload,
2288    ) -> Result<(), DeployError> {
2289        self.kv
2290            .put(
2291                &crate::compute::workload_key(project.as_str(), &workload.name),
2292                serde_json::to_vec(workload)?,
2293            )
2294            .await?;
2295        Ok(())
2296    }
2297
2298    /// Read a workload's desired state.
2299    pub async fn get_compute_workload(
2300        &self,
2301        project: ProjectRef<'_>,
2302        name: &str,
2303    ) -> Result<Option<crate::compute::ComputeWorkload>, DeployError> {
2304        match self
2305            .kv
2306            .get(&crate::compute::workload_key(project.as_str(), name))
2307            .await?
2308        {
2309            Some(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
2310            None => Ok(None),
2311        }
2312    }
2313
2314    /// List a project's compute workloads' desired state.
2315    pub async fn list_compute_workloads(
2316        &self,
2317        project: ProjectRef<'_>,
2318    ) -> Result<Vec<crate::compute::ComputeWorkload>, DeployError> {
2319        let prefix = crate::compute::workloads_prefix(project.as_str());
2320        let mut out = Vec::new();
2321        for key in self.kv.list_prefix(&prefix).await? {
2322            if let Some(bytes) = self.kv.get(&key).await? {
2323                if let Ok(w) = serde_json::from_slice::<crate::compute::ComputeWorkload>(&bytes) {
2324                    out.push(w);
2325                }
2326            }
2327        }
2328        Ok(out)
2329    }
2330
2331    /// List **every** compute workload across **all** projects, each paired with
2332    /// its owning project — the cross-project fan-out the scheduler runs.
2333    pub async fn list_compute_workloads_all(
2334        &self,
2335    ) -> Result<Vec<(String, crate::compute::ComputeWorkload)>, DeployError> {
2336        let mut out = Vec::new();
2337        for project in self.discover_projects().await? {
2338            for w in self
2339                .list_compute_workloads(ProjectRef::new(&project))
2340                .await?
2341            {
2342                out.push((project.clone(), w));
2343            }
2344        }
2345        Ok(out)
2346    }
2347
2348    /// Remove a workload's desired state (the executor then stops its replicas).
2349    /// Returns whether one existed.
2350    pub async fn delete_compute_workload(
2351        &self,
2352        project: ProjectRef<'_>,
2353        name: &str,
2354    ) -> Result<bool, DeployError> {
2355        let key = crate::compute::workload_key(project.as_str(), name);
2356        let existed = self.kv.get(&key).await?.is_some();
2357        if existed {
2358            self.kv.delete(&key).await?;
2359        }
2360        Ok(existed)
2361    }
2362
2363    /// Persist a replica's observed state at
2364    /// `project/<proj>/compute_state/<workload>/<replica>` (the reconcile loop's
2365    /// record + the gateway's upstream source).
2366    pub async fn set_replica_state(
2367        &self,
2368        project: ProjectRef<'_>,
2369        state: &crate::compute::ObservedInstance,
2370    ) -> Result<(), DeployError> {
2371        self.kv
2372            .put(
2373                &crate::compute::replica_state_key(
2374                    project.as_str(),
2375                    &state.handle.workload,
2376                    state.handle.replica,
2377                ),
2378                serde_json::to_vec(state)?,
2379            )
2380            .await?;
2381        Ok(())
2382    }
2383
2384    /// List a workload's observed replica states. Backfills each record's
2385    /// `handle.project` (and its parked snapshot's `project`) from the scoping
2386    /// `project` when a pre-v0.3.12 record left it empty, so the identity/IPAM key
2387    /// the backend derives always reflects the real owning project — never `""`.
2388    pub async fn list_replica_states(
2389        &self,
2390        project: ProjectRef<'_>,
2391        workload: &str,
2392    ) -> Result<Vec<crate::compute::ObservedInstance>, DeployError> {
2393        let mut out = Vec::new();
2394        for key in self
2395            .kv
2396            .list_prefix(&crate::compute::replica_state_prefix(
2397                project.as_str(),
2398                workload,
2399            ))
2400            .await?
2401        {
2402            if let Some(bytes) = self.kv.get(&key).await? {
2403                if let Ok(mut state) =
2404                    serde_json::from_slice::<crate::compute::ObservedInstance>(&bytes)
2405                {
2406                    backfill_replica_project(&mut state, project.as_str());
2407                    out.push(state);
2408                }
2409            }
2410        }
2411        Ok(out)
2412    }
2413
2414    /// List **all** observed replica states across every project's workloads (the
2415    /// gateway's dynamic-pool source). Fans out over
2416    /// [`discover_projects`](Self::discover_projects).
2417    pub async fn list_all_replica_states(
2418        &self,
2419    ) -> Result<Vec<crate::compute::ObservedInstance>, DeployError> {
2420        let mut out = Vec::new();
2421        for project in self.discover_projects().await? {
2422            let prefix = crate::compute::replica_states_project_prefix(&project);
2423            for key in self.kv.list_prefix(&prefix).await? {
2424                if let Some(bytes) = self.kv.get(&key).await? {
2425                    if let Ok(mut state) =
2426                        serde_json::from_slice::<crate::compute::ObservedInstance>(&bytes)
2427                    {
2428                        // Backfill the owning project from the KV key so a legacy
2429                        // (pre-v0.3.12) record's identity reflects its real project —
2430                        // this is the adoption/reconcile backfill point.
2431                        backfill_replica_project(&mut state, &project);
2432                        out.push(state);
2433                    }
2434                }
2435            }
2436        }
2437        Ok(out)
2438    }
2439
2440    /// Remove a replica's observed state.
2441    pub async fn delete_replica_state(
2442        &self,
2443        project: ProjectRef<'_>,
2444        workload: &str,
2445        replica: u32,
2446    ) -> Result<(), DeployError> {
2447        self.kv
2448            .delete(&crate::compute::replica_state_key(
2449                project.as_str(),
2450                workload,
2451                replica,
2452            ))
2453            .await?;
2454        Ok(())
2455    }
2456
2457    // ---- Projects (the owning Workspace boundary, 0.2.0) -------------------
2458    // A project is content-addressed + atomically activated exactly like a site: an
2459    // immutable `projectver/<hash>` spec body, a mutable `projectmeta/<name>` pointer,
2460    // and a bounded history ring. Membership is positional — the `project/<name>/`
2461    // prefix is the authoritative member set (this is what `delete_project` scans).
2462    // `owner/<kind>/<name>` is a migration-built derived hint, not maintained here and
2463    // not consulted for any decision (see the `boatramp_types::project` module docs).
2464
2465    /// Create or update a project: store its content-addressed spec body (idempotent)
2466    /// and flip the `projectmeta/<name>` pointer to it, recording the prior pointer in
2467    /// the history ring for rollback.
2468    pub async fn put_project(&self, p: &crate::project::Project) -> Result<String, DeployError> {
2469        let hash = p.id();
2470        let body = serde_json::to_vec(p).map_err(|e| DeployError::Serde(e.to_string()))?;
2471        let pointer = crate::project::pointer_key(&p.name);
2472        // Record the currently-active version in history (most-recent first, capped),
2473        // so a `rollback_project` can restore it.
2474        let mut history: Vec<String> =
2475            match self.kv.get(&crate::project::history_key(&p.name)).await? {
2476                Some(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
2477                None => Vec::new(),
2478            };
2479        if let Some(current) = self.kv.get(&pointer).await? {
2480            let current = String::from_utf8_lossy(&current).into_owned();
2481            if current != hash {
2482                history.retain(|h| h != &current);
2483                history.insert(0, current);
2484                history.truncate(MAX_HISTORY);
2485            }
2486        }
2487        self.kv
2488            .write_batch(vec![
2489                WriteOp::Put(crate::project::spec_key(&hash), body),
2490                WriteOp::Put(pointer, hash.clone().into_bytes()),
2491                WriteOp::Put(
2492                    crate::project::history_key(&p.name),
2493                    serde_json::to_vec(&history).map_err(|e| DeployError::Serde(e.to_string()))?,
2494                ),
2495            ])
2496            .await?;
2497        Ok(hash)
2498    }
2499
2500    /// The canonical record for the reserved `default` project — the single source
2501    /// of its shape, shared by [`Self::ensure_default_project`], the migration's
2502    /// `EnsureDefaultProject` step, and the reader-side backstop in
2503    /// [`Self::get_project`] / [`Self::list_projects`].
2504    pub fn default_project_record() -> crate::project::Project {
2505        crate::project::Project {
2506            version: crate::SCHEMA_VERSION,
2507            name: crate::project::DEFAULT_PROJECT.to_string(),
2508            created_at: now_unix(),
2509            meta: crate::project::ProjectMeta::default(),
2510            config: crate::project::ProjectConfig::default(),
2511            secrets_ref: None,
2512        }
2513    }
2514
2515    /// Idempotently materialize the reserved `default` project's entity record, so
2516    /// `project ls` / `project show default` reflect it on a fresh install just as
2517    /// they do on a migrated store. Presence-checked and content-addressed, so it is
2518    /// safe to call on every boot; in cluster mode the write forwards to the leader
2519    /// and concurrent callers converge (identical body). Returns whether it created
2520    /// the record. The reserved name is defined to *always* exist — this makes that
2521    /// true in the store, not only in the reader backstop below.
2522    pub async fn ensure_default_project(&self) -> Result<bool, DeployError> {
2523        let pointer = crate::project::pointer_key(crate::project::DEFAULT_PROJECT);
2524        if self.kv.get(&pointer).await?.is_some() {
2525            return Ok(false);
2526        }
2527        let default = Self::default_project_record();
2528        let hash = default.id();
2529        let body = serde_json::to_vec(&default).map_err(|e| DeployError::Serde(e.to_string()))?;
2530        self.kv
2531            .write_batch(vec![
2532                WriteOp::Put(crate::project::spec_key(&hash), body),
2533                WriteOp::Put(pointer, hash.into_bytes()),
2534            ])
2535            .await?;
2536        Ok(true)
2537    }
2538
2539    /// Cheap existence check for a project entity — whether its `projectmeta/<name>`
2540    /// pointer is present. Used by the project-scope middleware to reject an
2541    /// operation on a project that was never created (rather than silently
2542    /// manufacturing a ghost). The reserved `default` always exists.
2543    pub async fn project_exists(&self, name: &str) -> Result<bool, DeployError> {
2544        if name == crate::project::DEFAULT_PROJECT {
2545            return Ok(true);
2546        }
2547        Ok(self
2548            .kv
2549            .get(&crate::project::pointer_key(name))
2550            .await?
2551            .is_some())
2552    }
2553
2554    /// Load a project's active version, if it exists.
2555    pub async fn get_project(
2556        &self,
2557        name: &str,
2558    ) -> Result<Option<crate::project::Project>, DeployError> {
2559        let Some(hash) = self.kv.get(&crate::project::pointer_key(name)).await? else {
2560            // Reader-side backstop: the reserved `default` project always exists
2561            // conceptually, even before the boot-time ensure has run (or on a
2562            // read-only replica), so `project show default` never 404s.
2563            if name == crate::project::DEFAULT_PROJECT {
2564                return Ok(Some(Self::default_project_record()));
2565            }
2566            return Ok(None);
2567        };
2568        let hash = String::from_utf8_lossy(&hash).into_owned();
2569        match self.kv.get(&crate::project::spec_key(&hash)).await? {
2570            Some(bytes) => Ok(Some(
2571                serde_json::from_slice(&bytes).map_err(|e| DeployError::Serde(e.to_string()))?,
2572            )),
2573            // dangling pointer (body GC'd) reads as absent — except `default`, which
2574            // always resolves (same backstop as the missing-pointer case above).
2575            None if name == crate::project::DEFAULT_PROJECT => {
2576                Ok(Some(Self::default_project_record()))
2577            }
2578            None => Ok(None),
2579        }
2580    }
2581
2582    /// Every declared project, sorted by name. (A project may also exist only
2583    /// *implicitly* as a `project/<name>/` key prefix without a `projectmeta`
2584    /// pointer — see [`discover_projects`](Self::discover_projects) for that union.)
2585    pub async fn list_projects(&self) -> Result<Vec<crate::project::Project>, DeployError> {
2586        let mut out = Vec::new();
2587        for key in self.kv.list_prefix(crate::project::POINTER_PREFIX).await? {
2588            let Some(name) = key.strip_prefix(crate::project::POINTER_PREFIX) else {
2589                continue;
2590            };
2591            if !name.is_empty() {
2592                if let Some(p) = self.get_project(name).await? {
2593                    out.push(p);
2594                }
2595            }
2596        }
2597        // Reader-side backstop: the reserved `default` project always exists, even
2598        // before the boot-time ensure has run, so `project ls` never omits it.
2599        if !out
2600            .iter()
2601            .any(|p| p.name == crate::project::DEFAULT_PROJECT)
2602        {
2603            out.push(Self::default_project_record());
2604        }
2605        out.sort_by(|a, b| a.name.cmp(&b.name));
2606        Ok(out)
2607    }
2608
2609    /// Delete a project's pointer + history. Refuses (with [`DeployError::Conflict`])
2610    /// while the project still owns any resource — a project is deleted only once
2611    /// empty, so a stray site/function/compute can't be silently orphaned. The
2612    /// content-addressed spec bodies are shared + left to `prune`. Deleting the
2613    /// reserved `default` project is refused outright.
2614    pub async fn delete_project(&self, name: &str) -> Result<bool, DeployError> {
2615        if name == crate::project::DEFAULT_PROJECT {
2616            return Err(DeployError::Conflict(
2617                "the `default` project cannot be deleted".to_string(),
2618            ));
2619        }
2620        let existed = self
2621            .kv
2622            .get(&crate::project::pointer_key(name))
2623            .await?
2624            .is_some();
2625        // Refuse if any owned resource remains under `project/<name>/`, and — so the
2626        // operator never has to GUESS what's blocking — name exactly what's left,
2627        // grouped by family (functions / sites / compute / graphql / secret / …) with
2628        // the resource names. The failsafe stands: a project is deleted only once
2629        // empty; `project rm --force` cascades the teardown instead.
2630        let prefix = crate::project::resource_prefix(name);
2631        let remaining = self.kv.list_prefix(&prefix).await?;
2632        if !remaining.is_empty() {
2633            return Err(DeployError::Conflict(format!(
2634                "project `{name}` still owns resources — delete these first, or \
2635                 `project rm --force` to cascade: {}",
2636                Self::summarize_owned_resources(&prefix, &remaining)
2637            )));
2638        }
2639        self.kv
2640            .write_batch(vec![
2641                WriteOp::Delete(crate::project::pointer_key(name)),
2642                WriteOp::Delete(crate::project::history_key(name)),
2643            ])
2644            .await?;
2645        Ok(existed)
2646    }
2647
2648    /// Summarize the keys still owned under a project `prefix`, grouped by family (the
2649    /// first path segment after `project/<name>/`) with the distinct resource names
2650    /// (the second segment) — so [`delete_project`](Self::delete_project)'s refusal names
2651    /// exactly what to delete instead of leaving the operator to grep the KV. Bounded to
2652    /// 10 names per family so the message stays legible.
2653    fn summarize_owned_resources(prefix: &str, keys: &[String]) -> String {
2654        use std::collections::{BTreeMap, BTreeSet};
2655        // family → (distinct resource names, total key count)
2656        let mut fam: BTreeMap<&str, (BTreeSet<&str>, usize)> = BTreeMap::new();
2657        for key in keys {
2658            let rest = key
2659                .strip_prefix(prefix)
2660                .unwrap_or(key)
2661                .trim_start_matches('/');
2662            let mut segs = rest.split('/');
2663            let Some(family) = segs.next().filter(|f| !f.is_empty()) else {
2664                continue;
2665            };
2666            let entry = fam.entry(family).or_default();
2667            entry.1 += 1;
2668            if let Some(name) = segs.next().filter(|n| !n.is_empty()) {
2669                entry.0.insert(name);
2670            }
2671        }
2672        fam.iter()
2673            .map(|(family, (names, count))| {
2674                if names.is_empty() {
2675                    format!(
2676                        "{family} ({count} key{})",
2677                        if *count == 1 { "" } else { "s" }
2678                    )
2679                } else {
2680                    let shown: Vec<&str> = names.iter().take(10).copied().collect();
2681                    let more = names.len() - shown.len();
2682                    let suffix = if more > 0 {
2683                        format!(", +{more} more")
2684                    } else {
2685                        String::new()
2686                    };
2687                    format!("{family}: [{}{suffix}]", shown.join(", "))
2688                }
2689            })
2690            .collect::<Vec<_>>()
2691            .join("; ")
2692    }
2693
2694    /// Enumerate everything a project owns, as a structured, serializable plan — the
2695    /// shared source of truth for both the `project rm --force` **dry-run preview**
2696    /// and the **cascade loop** that executes the teardown. Reporting is read-only:
2697    /// it mutates nothing.
2698    ///
2699    /// Each compute workload's active [`ComputeSpec`](crate::compute::ComputeSpec) is
2700    /// resolved so the plan carries the **volume names** to reclaim — captured here,
2701    /// *before* teardown, because deleting the workload drops the pointer to its spec.
2702    ///
2703    /// `other_families` counts any remaining `project/<proj>/<family>/…` key families
2704    /// not surfaced as a first-class field (same family-grouping as
2705    /// [`summarize_owned_resources`](Self::summarize_owned_resources)) — a
2706    /// forward-compatible catch-all so a newly-added resource family still shows up in
2707    /// the preview and is covered by [`purge_project`](Self::purge_project)'s sweep.
2708    pub async fn enumerate_project_resources(
2709        &self,
2710        project: &str,
2711    ) -> Result<ProjectTeardownPlan, DeployError> {
2712        let pref = ProjectRef::new(project);
2713
2714        // Sites = the union of activated sites (a `current/<site>` pointer) AND
2715        // config-only sites (a `site/<site>` config pointer with no live deployment).
2716        // The cascade must `delete_site` every one — a config-only site still owns a
2717        // global `domain/*`/`wildcard/*` claim that only `delete_site` frees.
2718        let mut site_set: std::collections::BTreeSet<String> =
2719            self.list_sites(pref).await?.into_iter().collect();
2720        let site_pref = keys::site_prefix(pref);
2721        for key in self.kv.list_prefix(&site_pref).await? {
2722            if let Some(site) = key.strip_prefix(&site_pref).filter(|s| !s.is_empty()) {
2723                site_set.insert(site.to_string());
2724            }
2725        }
2726        let sites: Vec<String> = site_set.into_iter().collect();
2727
2728        let functions: Vec<String> = self
2729            .list_stored_functions(pref)
2730            .await?
2731            .into_iter()
2732            .map(|f| f.name)
2733            .collect();
2734
2735        // Compute workloads + the volumes their active spec mounts (captured now,
2736        // before the workload — and thus its spec pointer — is deleted).
2737        let mut compute = Vec::new();
2738        for w in self.list_compute_workloads(pref).await? {
2739            let volumes = match self.get_compute_spec(&w.active).await? {
2740                Some(spec) => spec.volumes.into_iter().map(|v| v.name).collect(),
2741                None => Vec::new(),
2742            };
2743            compute.push(ComputeTeardown {
2744                name: w.name,
2745                volumes,
2746            });
2747        }
2748
2749        // Secrets live under `project/<proj>/secret/<name>` in this same control-plane
2750        // KV, so the residual sweep would already reach them — but we surface the names
2751        // for the preview.
2752        let secret_prefix = keys::secret_prefix(pref);
2753        let mut secrets: Vec<String> = self
2754            .kv
2755            .list_prefix(&secret_prefix)
2756            .await?
2757            .into_iter()
2758            .filter_map(|k| k.strip_prefix(&secret_prefix).map(str::to_string))
2759            .filter(|n| !n.is_empty())
2760            .collect();
2761        secrets.sort();
2762
2763        // GraphQL safelist + subgraphs live OUTSIDE `project/<proj>/…` (under `hapq/…`
2764        // and `graphql/…`), so the residual sweep does not reach them — the cascade
2765        // clears them explicitly (see purge_project).
2766        let safelist = self
2767            .kv
2768            .list_prefix(&keys::graphql_safelist_prefix(pref))
2769            .await?
2770            .len();
2771
2772        let subgraph_prefix = keys::graphql_subgraph_prefix(pref);
2773        let mut subgraphs: Vec<String> = self
2774            .kv
2775            .list_prefix(&subgraph_prefix)
2776            .await?
2777            .into_iter()
2778            .filter_map(|k| k.strip_prefix(&subgraph_prefix).map(str::to_string))
2779            .filter(|n| !n.is_empty())
2780            .collect();
2781        subgraphs.sort();
2782
2783        // Any residual `project/<proj>/<family>/…` families not surfaced above,
2784        // counted by family — the forward-compatible catch-all.
2785        let resource_prefix = crate::project::resource_prefix(project);
2786        let residual = self.kv.list_prefix(&resource_prefix).await?;
2787        let covered = ["site", "functions", "compute", "secret"];
2788        let mut other_families: std::collections::BTreeMap<String, usize> = Default::default();
2789        for key in &residual {
2790            let rest = key
2791                .strip_prefix(&resource_prefix)
2792                .unwrap_or(key)
2793                .trim_start_matches('/');
2794            let Some(family) = rest.split('/').next().filter(|f| !f.is_empty()) else {
2795                continue;
2796            };
2797            if covered.contains(&family) {
2798                continue;
2799            }
2800            *other_families.entry(family.to_string()).or_default() += 1;
2801        }
2802
2803        Ok(ProjectTeardownPlan {
2804            project: project.to_string(),
2805            sites,
2806            functions,
2807            compute,
2808            secrets,
2809            safelist,
2810            subgraphs,
2811            other_families,
2812        })
2813    }
2814
2815    /// The **backstop after external teardown**: sweep every remaining key a project
2816    /// owns and remove the project itself. Called by the `--force` cascade *after* the
2817    /// explicit per-resource deletes (sites/functions/compute/secrets/graphql), it
2818    /// clears anything they left plus the project's own pointer/history/reverse-index —
2819    /// so no orphan can keep the project half-alive. Returns the count of keys purged.
2820    ///
2821    /// Scoped strictly to this project. It removes:
2822    /// - every `project/<project>/…` key (all owned resource state),
2823    /// - the whole GraphQL registry (`graphql/<project>/…`) and safelist (`hapq/<project>/…`),
2824    ///   which live outside the resource prefix,
2825    /// - the project pointer (`projectmeta/<project>`) + its history ring
2826    ///   (mirroring [`delete_project`](Self::delete_project)'s empty-path cleanup),
2827    /// - the `owner/<kind>/<name>` reverse-index entries **whose value is this project**.
2828    ///
2829    /// It never touches another project or the global content-addressed store
2830    /// (`projectver/`, `siteconfig/`, `computever/`, blob `{hh}/{hash}`, `manifests/`, …) —
2831    /// those bodies are shared and left to `prune`. Refuses the reserved `default`.
2832    pub async fn purge_project(&self, project: &str) -> Result<usize, DeployError> {
2833        if project == crate::project::DEFAULT_PROJECT {
2834            return Err(DeployError::Conflict(
2835                "the `default` project cannot be deleted".to_string(),
2836            ));
2837        }
2838        // Hold the domain-claim lock: delete_site already ran per-site under it, but a
2839        // residual `project/<proj>/…` routing key (if any) is swept here, so serialize
2840        // against concurrent domain claims for consistency.
2841        let _claim = self.domain_claim_lock.lock().await;
2842
2843        let pref = ProjectRef::new(project);
2844        let mut ops: Vec<WriteOp> = Vec::new();
2845
2846        // 1. Every owned resource key.
2847        for key in self
2848            .kv
2849            .list_prefix(&crate::project::resource_prefix(project))
2850            .await?
2851        {
2852            ops.push(WriteOp::Delete(key));
2853        }
2854        // 2. GraphQL registry + safelist (outside the resource prefix).
2855        for key in self
2856            .kv
2857            .list_prefix(&keys::graphql_registry_prefix(pref))
2858            .await?
2859        {
2860            ops.push(WriteOp::Delete(key));
2861        }
2862        for key in self
2863            .kv
2864            .list_prefix(&keys::graphql_safelist_prefix(pref))
2865            .await?
2866        {
2867            ops.push(WriteOp::Delete(key));
2868        }
2869        // 3. The project pointer + history ring (matches delete_project's cleanup).
2870        ops.push(WriteOp::Delete(crate::project::pointer_key(project)));
2871        ops.push(WriteOp::Delete(crate::project::history_key(project)));
2872        // 4. The reverse-index entries this project owns (value == project name).
2873        for key in self.kv.list_prefix(crate::project::OWNER_PREFIX).await? {
2874            if let Some(bytes) = self.kv.get(&key).await? {
2875                if bytes == project.as_bytes() {
2876                    ops.push(WriteOp::Delete(key));
2877                }
2878            }
2879        }
2880
2881        let purged = ops.len();
2882        if !ops.is_empty() {
2883            self.kv.write_batch(ops).await?;
2884        }
2885        // Any freed hosts must stop resolving — invalidate the resolve cache.
2886        self.bump_domain_epoch();
2887        Ok(purged)
2888    }
2889
2890    /// Atomically point `site` at deployment `id`.
2891    ///
2892    /// Refuses to activate a deployment whose blobs are not all present.
2893    pub async fn activate(
2894        &self,
2895        project: ProjectRef<'_>,
2896        site: &str,
2897        id: &str,
2898    ) -> Result<(), DeployError> {
2899        let manifest = self
2900            .get_manifest(id)
2901            .await?
2902            .ok_or_else(|| DeployError::NotFound(format!("deployment {id}")))?;
2903        let missing = self.missing_blobs(&manifest).await?;
2904        if !missing.is_empty() {
2905            return Err(DeployError::Incomplete(missing));
2906        }
2907
2908        // The atomic switch: a single KV write, so readers see the old or new
2909        // deployment in full, never a partial state.
2910        self.kv
2911            .put(&keys::current(project, site), id.as_bytes().to_vec())
2912            .await?;
2913
2914        // Record the activation. Best-effort: `current` above is the source of
2915        // truth, so a history-write failure must not fail an activation that
2916        // already took effect.
2917        let _ = self.record_history(project, site, id).await;
2918        Ok(())
2919    }
2920
2921    /// Prepend `id` to `site`'s activation history, de-duplicating by id and
2922    /// keeping at most [`MAX_HISTORY`] entries.
2923    async fn record_history(
2924        &self,
2925        project: ProjectRef<'_>,
2926        site: &str,
2927        id: &str,
2928    ) -> Result<(), DeployError> {
2929        let mut history = self.history(project, site).await.unwrap_or_default();
2930        history.retain(|entry| entry.id != id);
2931        history.insert(
2932            0,
2933            HistoryEntry {
2934                id: id.to_string(),
2935                at: now_unix(),
2936                meta: None,
2937            },
2938        );
2939        history.truncate(MAX_HISTORY);
2940        self.kv
2941            .put(&keys::history(project, site), serde_json::to_vec(&history)?)
2942            .await?;
2943        Ok(())
2944    }
2945
2946    /// A site's activation history, most recent first.
2947    pub async fn history(
2948        &self,
2949        project: ProjectRef<'_>,
2950        site: &str,
2951    ) -> Result<Vec<HistoryEntry>, DeployError> {
2952        match self.kv.get(&keys::history(project, site)).await? {
2953            Some(bytes) => Ok(serde_json::from_slice(&bytes)?),
2954            None => Ok(Vec::new()),
2955        }
2956    }
2957
2958    /// A site's current deployment plus its activation history, each history
2959    /// entry joined with its [`DeployMeta`] provenance (when recorded).
2960    pub async fn deployments(
2961        &self,
2962        project: ProjectRef<'_>,
2963        site: &str,
2964    ) -> Result<DeploymentList, DeployError> {
2965        let mut deployments = self.history(project, site).await?;
2966        for entry in &mut deployments {
2967            entry.meta = self.get_meta(&entry.id).await?;
2968        }
2969        Ok(DeploymentList {
2970            current: self.current_id(project, site).await?,
2971            deployments,
2972        })
2973    }
2974
2975    /// Deployment ids that must survive garbage collection: every `current`
2976    /// pointer, every named alias, and the history entries the retention policy
2977    /// in `opts` keeps (most-recent `keep_last` and/or anything within
2978    /// `keep_age_secs`; with neither set, the entire history).
2979    ///
2980    /// **Cross-project union.** Blob and manifest bodies are global CAS, shared
2981    /// across projects; reachability is therefore the **union** over every
2982    /// project's live set — a body is collectable only if *no* project references
2983    /// it. This fans out over [`discover_projects`](Self::discover_projects) and
2984    /// scans each project's `history/`, `current/`, and `alias/` families.
2985    async fn live_deployment_ids(&self, opts: &GcOptions) -> Result<BTreeSet<String>, DeployError> {
2986        let mut ids = BTreeSet::new();
2987        let now = now_unix();
2988        for project in self.discover_projects().await? {
2989            let pref = ProjectRef::new(&project);
2990            for key in self.kv.list_prefix(&keys::history_prefix(pref)).await? {
2991                if let Some(bytes) = self.kv.get(&key).await? {
2992                    if let Ok(history) = serde_json::from_slice::<Vec<HistoryEntry>>(&bytes) {
2993                        for (idx, entry) in history.iter().enumerate() {
2994                            let within_count = opts.keep_last.is_none_or(|n| idx < n);
2995                            let within_age = opts
2996                                .keep_age_secs
2997                                .is_some_and(|age| now.saturating_sub(entry.at) <= age);
2998                            if within_count || within_age {
2999                                ids.insert(entry.id.clone());
3000                            }
3001                        }
3002                    }
3003                }
3004            }
3005            // `current` pointers and named aliases are always live.
3006            for prefix in [keys::current_prefix(pref), keys::alias_project_prefix(pref)] {
3007                for key in self.kv.list_prefix(&prefix).await? {
3008                    if let Some(bytes) = self.kv.get(&key).await? {
3009                        ids.insert(String::from_utf8_lossy(&bytes).into_owned());
3010                    }
3011                }
3012            }
3013        }
3014        Ok(ids)
3015    }
3016
3017    /// Whether `id`'s manifest was first seen within the grace window — i.e. it
3018    /// may be an in-flight (uploaded-but-not-yet-activated) deploy that is not
3019    /// yet reachable. Manifests with no recorded `created_at` (pre-dating the
3020    /// metadata feature) are not grace-protected.
3021    async fn within_grace(
3022        &self,
3023        id: &str,
3024        now: u64,
3025        opts: &GcOptions,
3026    ) -> Result<bool, DeployError> {
3027        if opts.grace_secs == 0 {
3028            return Ok(false);
3029        }
3030        match self.get_meta(id).await? {
3031            Some(meta) => Ok(now.saturating_sub(meta.created_at) < opts.grace_secs),
3032            None => Ok(false),
3033        }
3034    }
3035
3036    /// Garbage-collect deployments unreachable from any site's `current`
3037    /// pointer, alias, or retained history, and the blobs no surviving
3038    /// deployment references.
3039    ///
3040    /// Equivalent to [`collect_garbage_with`](Self::collect_garbage_with) with
3041    /// default options (keep all history, no grace window).
3042    pub async fn collect_garbage(&self, prune: bool) -> Result<GcReport, DeployError> {
3043        self.collect_garbage_with(prune, GcOptions::default()).await
3044    }
3045
3046    /// Garbage-collect under an explicit retention policy and grace window.
3047    ///
3048    /// A manifest survives if it is reachable (see
3049    /// [`live_deployment_ids`](Self::live_deployment_ids)) **or** was first seen
3050    /// within `opts.grace_secs` — the latter protects an in-flight deploy whose
3051    /// manifest is stored and blobs are uploading but which is not yet
3052    /// activated. A blob survives if any surviving manifest references it.
3053    ///
3054    /// With `prune == false` nothing is deleted; the [`GcReport`] describes what
3055    /// *would* be removed.
3056    pub async fn collect_garbage_with(
3057        &self,
3058        prune: bool,
3059        opts: GcOptions,
3060    ) -> Result<GcReport, DeployError> {
3061        let live_ids = self.live_deployment_ids(&opts).await?;
3062        let now = now_unix();
3063
3064        let manifest_keys = self.kv.list_prefix("manifests/").await?;
3065        let manifests_total = manifest_keys.len();
3066        let mut referenced: BTreeSet<String> = BTreeSet::new();
3067        let mut orphan_manifests: Vec<String> = Vec::new();
3068        for key in &manifest_keys {
3069            let id = key.strip_prefix("manifests/").unwrap_or(key);
3070            let protected = live_ids.contains(id) || self.within_grace(id, now, &opts).await?;
3071            if protected {
3072                if let Some(bytes) = self.kv.get(key).await? {
3073                    if let Ok(manifest) = Manifest::from_bytes(&bytes) {
3074                        referenced.extend(manifest.blob_hashes());
3075                    }
3076                }
3077            } else {
3078                orphan_manifests.push(key.clone());
3079            }
3080        }
3081
3082        let blobs = self.storage.list("").await?;
3083        let blobs_total = blobs.len();
3084        let mut blobs_removed = 0;
3085        let mut bytes_reclaimed = 0;
3086        for meta in &blobs {
3087            if !is_blob_key(&meta.key) {
3088                continue;
3089            }
3090            let hash = meta.key.rsplit('/').next().unwrap_or(&meta.key);
3091            if !referenced.contains(hash) {
3092                blobs_removed += 1;
3093                bytes_reclaimed += meta.size.unwrap_or(0);
3094                if prune {
3095                    self.storage.delete(&meta.key).await?;
3096                }
3097            }
3098        }
3099
3100        // Orphaned content-addressed config bodies: a `siteconfig/<hash>` no
3101        // longer pointed to by any `site/<site>` (left behind by a config edit;
3102        // dedup means a body shared by several sites stays while any references
3103        // it). Tiny KV entries, cleaned under `prune` (not counted in the
3104        // blob/manifest totals, which are deploy-content concepts).
3105        if prune {
3106            // Union of site-config hashes referenced by **any** project's site
3107            // pointers — a `siteconfig/<hash>` body is global CAS, so it stays
3108            // while any project references it.
3109            let mut referenced_configs: BTreeSet<String> = BTreeSet::new();
3110            for project in self.discover_projects().await? {
3111                let site_prefix = keys::site_prefix(ProjectRef::new(&project));
3112                for pointer in self.kv.list_prefix(&site_prefix).await? {
3113                    if let Some(bytes) = self.kv.get(&pointer).await? {
3114                        referenced_configs.insert(String::from_utf8_lossy(&bytes).into_owned());
3115                    }
3116                }
3117            }
3118            for key in self.kv.list_prefix("siteconfig/").await? {
3119                let hash = key.strip_prefix("siteconfig/").unwrap_or(&key);
3120                if !referenced_configs.contains(hash) {
3121                    self.kv.delete(&key).await?;
3122                }
3123            }
3124        }
3125
3126        let manifests_removed = orphan_manifests.len();
3127        if prune {
3128            for key in &orphan_manifests {
3129                self.kv.delete(key).await?;
3130                // Drop the companion metadata record alongside the manifest.
3131                if let Some(id) = key.strip_prefix("manifests/") {
3132                    let _ = self.kv.delete(&keys::meta(id)).await;
3133                }
3134            }
3135        }
3136
3137        Ok(GcReport {
3138            manifests_total,
3139            manifests_removed,
3140            blobs_total,
3141            blobs_removed,
3142            bytes_reclaimed,
3143        })
3144    }
3145
3146    /// Verify every stored blob still hashes to its key — an integrity scrub
3147    /// that detects bit-rot or tampering. Each blob is streamed
3148    /// through a hasher (never fully buffered); read-only (never deletes). The
3149    /// serving path can't reject a corrupt blob without buffering, so this
3150    /// verification is performed offline.
3151    pub async fn scrub_blobs(&self) -> Result<ScrubReport, DeployError> {
3152        let blobs = self.storage.list("").await?;
3153        let mut report = ScrubReport::default();
3154        for meta in &blobs {
3155            if !is_blob_key(&meta.key) {
3156                continue;
3157            }
3158            report.checked += 1;
3159            let expected = meta
3160                .key
3161                .rsplit('/')
3162                .next()
3163                .unwrap_or(meta.key.as_str())
3164                .to_string();
3165            match self.hash_stored_object(&meta.key).await {
3166                Ok(actual) if actual == expected => {}
3167                Ok(actual) => report.mismatched.push(BlobMismatch {
3168                    key: meta.key.clone(),
3169                    expected,
3170                    actual,
3171                }),
3172                Err(err) => report.errors.push(BlobReadError {
3173                    key: meta.key.clone(),
3174                    error: err.to_string(),
3175                }),
3176            }
3177        }
3178        Ok(report)
3179    }
3180
3181    /// Drop these keys from the control-plane KV's local cache (shared-mode
3182    /// **push** invalidation). A Cloudflare DO /
3183    /// Queue (or any pusher) calls this — via the `/api/cache/invalidate`
3184    /// endpoint — when a peer changed those keys, for real-time invalidation
3185    /// without waiting on the poll interval. A no-op on an uncached/Raft store.
3186    pub fn invalidate_cache_keys(&self, keys: &[String]) {
3187        self.kv.invalidate_keys(keys);
3188    }
3189
3190    /// Drop the entire control-plane KV cache (the coarse fallback / `SIGHUP`
3191    /// equivalent over HTTP).
3192    pub fn invalidate_cache(&self) {
3193        self.kv.invalidate_cache();
3194    }
3195
3196    /// The key-free status (domain + expiry) of every cluster-managed cert in
3197    /// the control plane (`cert/<domain>`). Empty when certs
3198    /// live in a file cache instead (single-node `acme`). Never returns key
3199    /// material. Sorted by domain.
3200    pub async fn cert_status(&self) -> Result<Vec<crate::cert::CertStatus>, DeployError> {
3201        let mut out = Vec::new();
3202        for key in self.kv.list_prefix("cert/").await? {
3203            let domain = key.strip_prefix("cert/").unwrap_or(&key).to_string();
3204            if let Some(bytes) = self.kv.get(&key).await? {
3205                if let Ok(cert) = serde_json::from_slice::<crate::cert::StoredCert>(&bytes) {
3206                    out.push(crate::cert::CertStatus {
3207                        domain,
3208                        not_after_unix: cert.not_after_unix,
3209                    });
3210                }
3211            }
3212        }
3213        out.sort_by(|a, b| a.domain.cmp(&b.domain));
3214        Ok(out)
3215    }
3216
3217    /// Stream a stored object through a SHA-256 hasher and return the hex digest.
3218    async fn hash_stored_object(&self, key: &str) -> Result<String, DeployError> {
3219        let mut body = self.storage.get(key).await?.body;
3220        let mut hasher = Sha256::new();
3221        while let Some(chunk) = body.next().await {
3222            hasher.update(&chunk?);
3223        }
3224        Ok(hex::encode(hasher.finalize()))
3225    }
3226
3227    /// Every site in `project` that has a current deployment (i.e. a
3228    /// `project/<proj>/current/<site>` pointer). Used by the background scheduler
3229    /// to find which sites' consumers and crons to run.
3230    pub async fn list_sites(&self, project: ProjectRef<'_>) -> Result<Vec<String>, DeployError> {
3231        let prefix = keys::current_prefix(project);
3232        let keys = self.kv.list_prefix(&prefix).await?;
3233        Ok(keys
3234            .into_iter()
3235            .filter_map(|k| k.strip_prefix(&prefix).map(str::to_string))
3236            .collect())
3237    }
3238
3239    /// Every `(project, site)` with a current deployment across **all** projects —
3240    /// the cross-project fan-out for the scheduler/operator.
3241    pub async fn list_sites_all(&self) -> Result<Vec<(String, String)>, DeployError> {
3242        let mut out = Vec::new();
3243        for project in self.discover_projects().await? {
3244            for site in self.list_sites(ProjectRef::new(&project)).await? {
3245                out.push((project.clone(), site));
3246            }
3247        }
3248        Ok(out)
3249    }
3250
3251    /// Delete a site and its routing/config state (the Kubernetes operator's
3252    /// `Site` finalizer). Removes the config pointer, the current-deployment
3253    /// pointer, activation history, aliases, the domain-routing entries the site
3254    /// owns (so its hosts free up), and any pending domain verifications — all
3255    /// within `project`. The content-addressed deployment manifests + blobs are
3256    /// shared and left to `prune`. Idempotent (deleting an absent site is a no-op).
3257    pub async fn delete_site(
3258        &self,
3259        project: ProjectRef<'_>,
3260        site: &str,
3261    ) -> Result<(), DeployError> {
3262        use crate::kv::WriteOp;
3263        // Hold the domain-claim lock so a concurrent attach can't race the routing
3264        // deletes and leave a dangling `domain/*` → deleted-site entry.
3265        let _claim = self.domain_claim_lock.lock().await;
3266        let mut batch = vec![
3267            WriteOp::Delete(keys::site_pointer(project, site)),
3268            WriteOp::Delete(keys::current(project, site)),
3269            WriteOp::Delete(keys::history(project, site)),
3270        ];
3271        if let Some(config) = self.get_site_config(project, site).await? {
3272            for host in config.domains.exact_hosts() {
3273                batch.push(WriteOp::Delete(keys::domain(host)));
3274            }
3275            for wildcard in &config.domains.wildcards {
3276                if let Some(suffix) = wildcard.strip_prefix("*.") {
3277                    batch.push(WriteOp::Delete(keys::wildcard(suffix)));
3278                }
3279            }
3280        }
3281        for key in self
3282            .kv
3283            .list_prefix(&keys::alias_prefix(project, site))
3284            .await?
3285        {
3286            batch.push(WriteOp::Delete(key));
3287        }
3288        for key in self
3289            .kv
3290            .list_prefix(&keys::domain_verification_prefix(project, site))
3291            .await?
3292        {
3293            batch.push(WriteOp::Delete(key));
3294        }
3295        self.kv.write_batch(batch).await?;
3296        // Freed hosts must stop resolving to this site — invalidate the resolve cache.
3297        self.bump_domain_epoch();
3298        Ok(())
3299    }
3300
3301    /// The deployment id currently serving `site`, if any.
3302    pub async fn current_id(
3303        &self,
3304        project: ProjectRef<'_>,
3305        site: &str,
3306    ) -> Result<Option<String>, DeployError> {
3307        match self.kv.get(&keys::current(project, site)).await? {
3308            Some(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
3309            None => Ok(None),
3310        }
3311    }
3312
3313    /// The manifest currently serving `site`, if any.
3314    pub async fn current_manifest(
3315        &self,
3316        project: ProjectRef<'_>,
3317        site: &str,
3318    ) -> Result<Option<Manifest>, DeployError> {
3319        match self.current_id(project, site).await? {
3320            Some(id) => self.get_manifest(&id).await,
3321            None => Ok(None),
3322        }
3323    }
3324
3325    /// Resolve a request `path` against `site`'s current deployment.
3326    ///
3327    /// Applies a directory-index fallback: an empty/trailing-slash path, or a
3328    /// path with no matching file, falls back to `<path>/index.html`.
3329    pub async fn resolve(
3330        &self,
3331        project: ProjectRef<'_>,
3332        site: &str,
3333        path: &str,
3334    ) -> Result<Option<FileEntry>, DeployError> {
3335        let Some(manifest) = self.current_manifest(project, site).await? else {
3336            return Ok(None);
3337        };
3338        Ok(lookup(&manifest, path))
3339    }
3340}
3341
3342/// Look up `path` in `manifest`, applying the directory-index fallback.
3343fn lookup(manifest: &Manifest, path: &str) -> Option<FileEntry> {
3344    let trimmed = path.trim_start_matches('/');
3345    if let Some(entry) = manifest.files.get(trimmed) {
3346        return Some(entry.clone());
3347    }
3348    let index = if trimmed.is_empty() {
3349        "index.html".to_string()
3350    } else {
3351        format!("{}/index.html", trimmed.trim_end_matches('/'))
3352    };
3353    manifest.files.get(&index).cloned()
3354}
3355
3356#[cfg(test)]
3357mod tests {
3358    use super::*;
3359    use crate::config::DeployConfig;
3360    use crate::ObjectMeta;
3361
3362    fn entry(hash: &str) -> FileEntry {
3363        FileEntry {
3364            hash: hash.to_string(),
3365            size: 0,
3366            content_type: None,
3367            variants: BTreeMap::new(),
3368        }
3369    }
3370
3371    #[test]
3372    fn manifest_id_is_deterministic() {
3373        let mut a = Manifest::default();
3374        a.files.insert("index.html".into(), entry("aa"));
3375        a.files.insert("style.css".into(), entry("bb"));
3376
3377        let mut b = Manifest::default();
3378        // Insertion order differs; id must not.
3379        b.files.insert("style.css".into(), entry("bb"));
3380        b.files.insert("index.html".into(), entry("aa"));
3381
3382        assert_eq!(a.id().unwrap(), b.id().unwrap());
3383    }
3384
3385    #[test]
3386    fn manifest_carries_schema_version_and_reads_legacy() {
3387        // New manifests are stamped with the current version.
3388        let manifest = Manifest::default();
3389        assert_eq!(manifest.version, crate::SCHEMA_VERSION);
3390        assert!(manifest.to_bytes().unwrap().starts_with(b"{\"version\":1"));
3391
3392        // A version-less document (pre-field) still reads as v1.
3393        let legacy = br#"{"files":{},"config":{}}"#;
3394        assert_eq!(Manifest::from_bytes(legacy).unwrap().version, 1);
3395    }
3396
3397    #[test]
3398    fn directory_index_fallback() {
3399        let mut m = Manifest::default();
3400        m.files.insert("index.html".into(), entry("root"));
3401        m.files.insert("blog/index.html".into(), entry("blog"));
3402
3403        assert_eq!(lookup(&m, "").unwrap().hash, "root");
3404        assert_eq!(lookup(&m, "/").unwrap().hash, "root");
3405        assert_eq!(lookup(&m, "blog").unwrap().hash, "blog");
3406        assert_eq!(lookup(&m, "blog/").unwrap().hash, "blog");
3407        assert!(lookup(&m, "missing.html").is_none());
3408    }
3409
3410    /// A do-nothing blob backend, so we can exercise the KV-only site-config and
3411    /// host-routing logic without a real `Storage`.
3412    struct NullStorage;
3413
3414    #[async_trait::async_trait]
3415    impl Storage for NullStorage {
3416        async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
3417            Err(StorageError::NotFound(String::new()))
3418        }
3419        async fn get_range(
3420            &self,
3421            _: &str,
3422            _: u64,
3423            _: Option<u64>,
3424        ) -> Result<GetObject, StorageError> {
3425            Err(StorageError::NotFound(String::new()))
3426        }
3427        async fn put(
3428            &self,
3429            _: &str,
3430            _: ByteStream,
3431            _: PutMeta,
3432        ) -> Result<ObjectMeta, StorageError> {
3433            Err(StorageError::unsupported("null"))
3434        }
3435        async fn head(&self, _: &str) -> Result<ObjectMeta, StorageError> {
3436            Err(StorageError::NotFound(String::new()))
3437        }
3438        async fn delete(&self, _: &str) -> Result<(), StorageError> {
3439            Ok(())
3440        }
3441        async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
3442            Ok(Vec::new())
3443        }
3444    }
3445
3446    /// An in-memory blob store, so a GC test can observe real blob deletion.
3447    #[derive(Default)]
3448    struct MemStorage {
3449        objects: Mutex<std::collections::HashMap<String, Vec<u8>>>,
3450    }
3451
3452    #[async_trait::async_trait]
3453    impl Storage for MemStorage {
3454        async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
3455            let bytes = self
3456                .objects
3457                .lock()
3458                .unwrap()
3459                .get(key)
3460                .cloned()
3461                .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
3462            let size = bytes.len() as u64;
3463            let body: ByteStream =
3464                futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
3465            Ok(GetObject {
3466                meta: ObjectMeta {
3467                    key: key.to_string(),
3468                    size: Some(size),
3469                    ..Default::default()
3470                },
3471                body,
3472            })
3473        }
3474        async fn get_range(
3475            &self,
3476            key: &str,
3477            _: u64,
3478            _: Option<u64>,
3479        ) -> Result<GetObject, StorageError> {
3480            self.get(key).await
3481        }
3482        async fn put(
3483            &self,
3484            key: &str,
3485            mut body: ByteStream,
3486            _: PutMeta,
3487        ) -> Result<ObjectMeta, StorageError> {
3488            let mut buf = Vec::new();
3489            while let Some(chunk) = body.next().await {
3490                buf.extend_from_slice(&chunk?);
3491            }
3492            let size = buf.len() as u64;
3493            self.objects.lock().unwrap().insert(key.to_string(), buf);
3494            Ok(ObjectMeta {
3495                key: key.to_string(),
3496                size: Some(size),
3497                ..Default::default()
3498            })
3499        }
3500        async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
3501            let map = self.objects.lock().unwrap();
3502            let bytes = map
3503                .get(key)
3504                .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
3505            Ok(ObjectMeta {
3506                key: key.to_string(),
3507                size: Some(bytes.len() as u64),
3508                ..Default::default()
3509            })
3510        }
3511        async fn delete(&self, key: &str) -> Result<(), StorageError> {
3512            self.objects.lock().unwrap().remove(key);
3513            Ok(())
3514        }
3515        async fn list(&self, prefix: &str) -> Result<Vec<ObjectMeta>, StorageError> {
3516            Ok(self
3517                .objects
3518                .lock()
3519                .unwrap()
3520                .keys()
3521                .filter(|k| k.starts_with(prefix))
3522                .map(|k| ObjectMeta {
3523                    key: k.clone(),
3524                    ..Default::default()
3525                })
3526                .collect())
3527        }
3528    }
3529
3530    /// A single blob (`hash`) written to storage.
3531    fn once_bytes(b: &'static [u8]) -> ByteStream {
3532        futures::stream::once(async move { Ok(bytes::Bytes::from_static(b)) }).boxed()
3533    }
3534
3535    /// A manifest referencing the given `(path, blob-hash)` files.
3536    fn manifest_with(files: &[(&str, &str)]) -> Manifest {
3537        let mut m = Manifest::default();
3538        for (path, hash) in files {
3539            m.files.insert(
3540                (*path).to_string(),
3541                FileEntry {
3542                    hash: (*hash).to_string(),
3543                    size: 1,
3544                    content_type: None,
3545                    variants: Default::default(),
3546                },
3547            );
3548        }
3549        m
3550    }
3551
3552    #[tokio::test]
3553    async fn function_storage_versioning_alias_rollback() {
3554        use crate::function::{Function, FunctionConfig, Lifecycle, Owner};
3555        use crate::kv::MemoryKv;
3556
3557        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3558        assert!(store
3559            .list_stored_functions(ProjectRef::DEFAULT)
3560            .await
3561            .unwrap()
3562            .is_empty());
3563
3564        let mut f = Function::new(
3565            "resize",
3566            Owner::Project("acme".into()),
3567            "hashA",
3568            FunctionConfig::default(),
3569            Lifecycle::Independent,
3570            1,
3571        );
3572        store.put_function(ProjectRef::DEFAULT, &f).await.unwrap();
3573        assert_eq!(
3574            store
3575                .get_function(ProjectRef::DEFAULT, "resize")
3576                .await
3577                .unwrap()
3578                .unwrap()
3579                .active,
3580            "hashA"
3581        );
3582        assert_eq!(
3583            store
3584                .list_stored_functions(ProjectRef::DEFAULT)
3585                .await
3586                .unwrap()
3587                .len(),
3588            1
3589        );
3590
3591        // A new version + an alias, persisted and read back.
3592        f.upsert_version("hashB", Lifecycle::Independent, 2);
3593        f.set_alias("prod", "hashA").unwrap();
3594        store.put_function(ProjectRef::DEFAULT, &f).await.unwrap();
3595        let got = store
3596            .get_function(ProjectRef::DEFAULT, "resize")
3597            .await
3598            .unwrap()
3599            .unwrap();
3600        assert_eq!(got.active, "hashB");
3601        assert_eq!(got.aliases.get("prod").map(String::as_str), Some("hashA"));
3602
3603        // Delete is idempotent + reports prior existence.
3604        assert!(store
3605            .delete_function(ProjectRef::DEFAULT, "resize")
3606            .await
3607            .unwrap());
3608        assert!(store
3609            .get_function(ProjectRef::DEFAULT, "resize")
3610            .await
3611            .unwrap()
3612            .is_none());
3613        assert!(!store
3614            .delete_function(ProjectRef::DEFAULT, "resize")
3615            .await
3616            .unwrap());
3617    }
3618
3619    #[tokio::test]
3620    async fn delete_function_sweeps_the_whole_subtree_only_for_that_function() {
3621        use crate::function::keys as fk;
3622        use crate::kv::{KvStore, MemoryKv};
3623
3624        let kv = Arc::new(MemoryKv::new());
3625        let store = DeployStore::new(Arc::new(NullStorage), kv.clone());
3626        let proj = ProjectRef::new("acme");
3627        // Seed a function's meta + its whole subtree (version/alias/trigger/invocation)
3628        // + its separate metering record.
3629        for k in [
3630            fk::meta("acme", "greeter"),
3631            fk::version("acme", "greeter", "v1"),
3632            fk::alias("acme", "greeter", "prod"),
3633            fk::trigger("acme", "greeter", "t1"),
3634            fk::invocation("acme", "greeter", "i1"),
3635            fk::metering("acme", "greeter"),
3636        ] {
3637            kv.put(&k, vec![1]).await.unwrap();
3638        }
3639        // A sibling whose name shares the `greeter` prefix must be untouched.
3640        kv.put(&fk::version("acme", "greeterx", "z1"), vec![1])
3641            .await
3642            .unwrap();
3643
3644        assert!(store.delete_function(proj, "greeter").await.unwrap());
3645
3646        // Nothing left for `greeter`: meta, the whole subtree, and metering all gone.
3647        assert!(kv
3648            .get(&fk::meta("acme", "greeter"))
3649            .await
3650            .unwrap()
3651            .is_none());
3652        assert!(kv
3653            .list_prefix(&format!("{}/", fk::meta("acme", "greeter")))
3654            .await
3655            .unwrap()
3656            .is_empty());
3657        assert!(kv
3658            .get(&fk::metering("acme", "greeter"))
3659            .await
3660            .unwrap()
3661            .is_none());
3662        // The sibling is untouched (no over-match on the shared name prefix).
3663        assert!(kv
3664            .get(&fk::version("acme", "greeterx", "z1"))
3665            .await
3666            .unwrap()
3667            .is_some());
3668    }
3669
3670    #[tokio::test]
3671    async fn delete_project_refusal_names_what_remains() {
3672        use crate::function::keys as fk;
3673        use crate::kv::{KvStore, MemoryKv};
3674
3675        let kv = Arc::new(MemoryKv::new());
3676        let store = DeployStore::new(Arc::new(NullStorage), kv.clone());
3677        // Leftover owned resources under project/acme/: a function + two graphql safelist ops.
3678        kv.put(&fk::meta("acme", "greeter"), vec![1]).await.unwrap();
3679        kv.put("project/acme/graphql/safelist/op1", vec![1])
3680            .await
3681            .unwrap();
3682        kv.put("project/acme/graphql/safelist/op2", vec![1])
3683            .await
3684            .unwrap();
3685
3686        let msg = store.delete_project("acme").await.unwrap_err().to_string();
3687        // The refusal names exactly what's left (no guessing) + points at the cascade.
3688        assert!(msg.contains("functions: [greeter]"), "{msg}");
3689        assert!(msg.contains("graphql"), "{msg}");
3690        assert!(msg.contains("--force"), "{msg}");
3691    }
3692
3693    #[tokio::test]
3694    async fn function_invocation_and_idempotency_storage() {
3695        use crate::function::{
3696            Function, FunctionConfig, Invocation, InvocationResult, InvocationStatus, InvokeMode,
3697            Lifecycle, Owner,
3698        };
3699        use crate::kv::MemoryKv;
3700
3701        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3702        // A function whose invocation sub-keys must NOT leak into the function list.
3703        let f = Function::new(
3704            "greeter",
3705            Owner::Project("acme".into()),
3706            "hashA",
3707            FunctionConfig::default(),
3708            Lifecycle::Independent,
3709            1,
3710        );
3711        store.put_function(ProjectRef::DEFAULT, &f).await.unwrap();
3712
3713        let mut inv = Invocation {
3714            id: "inv-1".into(),
3715            function: "greeter".into(),
3716            version: "hashA".into(),
3717            mode: InvokeMode::Async,
3718            status: InvocationStatus::Queued,
3719            idempotency_key: Some("key-1".into()),
3720            attempts: 0,
3721            lease_expires: None,
3722            request_b64: None,
3723            request_content_type: None,
3724            result: None,
3725            created: 10,
3726            updated: 10,
3727        };
3728        store
3729            .put_invocation(ProjectRef::DEFAULT, &inv)
3730            .await
3731            .unwrap();
3732        store
3733            .put_idempotency(ProjectRef::DEFAULT, "greeter", "key-1", "inv-1")
3734            .await
3735            .unwrap();
3736
3737        // Read back, resolve the idempotency pointer, and list.
3738        assert_eq!(
3739            store
3740                .get_invocation(ProjectRef::DEFAULT, "greeter", "inv-1")
3741                .await
3742                .unwrap()
3743                .unwrap()
3744                .status,
3745            InvocationStatus::Queued
3746        );
3747        assert_eq!(
3748            store
3749                .get_idempotency(ProjectRef::DEFAULT, "greeter", "key-1")
3750                .await
3751                .unwrap(),
3752            Some("inv-1".to_string())
3753        );
3754        assert_eq!(
3755            store
3756                .list_invocations(ProjectRef::DEFAULT, "greeter")
3757                .await
3758                .unwrap()
3759                .len(),
3760            1
3761        );
3762        // The invocation + idempotency sub-keys must not be mistaken for functions.
3763        assert_eq!(
3764            store
3765                .list_stored_functions(ProjectRef::DEFAULT)
3766                .await
3767                .unwrap()
3768                .len(),
3769            1
3770        );
3771
3772        // Transition to a terminal, captured result.
3773        inv.status = InvocationStatus::Succeeded;
3774        inv.attempts = 1;
3775        inv.result = Some(InvocationResult {
3776            status: 200,
3777            content_type: Some("text/plain".into()),
3778            body_b64: "aGVsbG8=".into(),
3779        });
3780        inv.updated = 20;
3781        store
3782            .put_invocation(ProjectRef::DEFAULT, &inv)
3783            .await
3784            .unwrap();
3785        let got = store
3786            .get_invocation(ProjectRef::DEFAULT, "greeter", "inv-1")
3787            .await
3788            .unwrap()
3789            .unwrap();
3790        assert!(got.is_terminal());
3791        assert_eq!(got.result.unwrap().status, 200);
3792
3793        // An unrecorded key resolves to nothing.
3794        assert!(store
3795            .get_idempotency(ProjectRef::DEFAULT, "greeter", "absent")
3796            .await
3797            .unwrap()
3798            .is_none());
3799    }
3800
3801    #[tokio::test]
3802    async fn notification_ledger_provisions_and_retracts_through_the_store() {
3803        use crate::blob_notify::{ManagedResource, ProvisionTier};
3804        use crate::blob_provision::{ensure_watch, retract_watch, ProvisionError, WatchProvider};
3805        use crate::kv::MemoryKv;
3806
3807        // A minimal provider standing in for a cloud SDK.
3808        struct StoreMock;
3809        #[async_trait::async_trait]
3810        impl WatchProvider for StoreMock {
3811            fn name(&self) -> &str {
3812                "mock"
3813            }
3814            fn recipe(&self, _prefix: &str) -> String {
3815                String::new()
3816            }
3817            async fn provision(
3818                &self,
3819                _prefix: &str,
3820            ) -> Result<Vec<ManagedResource>, ProvisionError> {
3821                Ok(vec![ManagedResource::new("queue", "q-1")])
3822            }
3823            async fn verify(&self, _prefix: &str) -> Result<bool, ProvisionError> {
3824                Ok(true)
3825            }
3826            async fn retract(&self, _res: &[ManagedResource]) -> Result<(), ProvisionError> {
3827                Ok(())
3828            }
3829        }
3830
3831        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3832        assert!(store
3833            .get_managed_notification(ProjectRef::DEFAULT, "ingest", "uploads/")
3834            .await
3835            .unwrap()
3836            .is_none());
3837
3838        // Provision through the real store (its `LedgerSink` impl) → recorded.
3839        let provider = StoreMock;
3840        let out = ensure_watch(
3841            &provider,
3842            ProvisionTier::Provision,
3843            "ingest",
3844            "uploads/",
3845            &store,
3846            7,
3847        )
3848        .await
3849        .unwrap();
3850        assert!(matches!(
3851            out,
3852            crate::blob_provision::ProvisionOutcome::Ready
3853        ));
3854        let record = store
3855            .get_managed_notification(ProjectRef::DEFAULT, "ingest", "uploads/")
3856            .await
3857            .unwrap()
3858            .expect("the pipeline is recorded in the store ledger");
3859        assert_eq!(record.provider, "mock");
3860        assert_eq!(
3861            store
3862                .list_managed_notifications(ProjectRef::DEFAULT, "ingest")
3863                .await
3864                .unwrap()
3865                .len(),
3866            1
3867        );
3868
3869        // Retract removes the ledger entry.
3870        retract_watch(&provider, &record, &store).await.unwrap();
3871        assert!(store
3872            .get_managed_notification(ProjectRef::DEFAULT, "ingest", "uploads/")
3873            .await
3874            .unwrap()
3875            .is_none());
3876    }
3877
3878    #[tokio::test]
3879    async fn workflow_definition_and_run_storage() {
3880        use crate::kv::MemoryKv;
3881        use crate::workflow::{Step, Workflow, WorkflowRun};
3882
3883        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3884        assert!(store
3885            .get_workflow(ProjectRef::DEFAULT, "etl")
3886            .await
3887            .unwrap()
3888            .is_none());
3889        assert!(store
3890            .list_workflows(ProjectRef::DEFAULT)
3891            .await
3892            .unwrap()
3893            .is_empty());
3894
3895        let wf = Workflow {
3896            name: "etl".into(),
3897            steps: vec![
3898                Step {
3899                    id: "a".into(),
3900                    function: "extract".into(),
3901                    depends_on: vec![],
3902                    retry: Default::default(),
3903                    compensate: None,
3904                },
3905                Step {
3906                    id: "b".into(),
3907                    function: "load".into(),
3908                    depends_on: vec!["a".into()],
3909                    retry: Default::default(),
3910                    compensate: None,
3911                },
3912            ],
3913        };
3914        store.put_workflow(ProjectRef::DEFAULT, &wf).await.unwrap();
3915        assert_eq!(
3916            store
3917                .get_workflow(ProjectRef::DEFAULT, "etl")
3918                .await
3919                .unwrap()
3920                .unwrap(),
3921            wf
3922        );
3923        assert_eq!(
3924            store
3925                .list_workflows(ProjectRef::DEFAULT)
3926                .await
3927                .unwrap()
3928                .len(),
3929            1
3930        );
3931
3932        // A run is stored under the workflow's runs prefix — not mistaken for a def.
3933        let run = WorkflowRun::start(&wf, "r1", None, 5);
3934        store
3935            .put_workflow_run(ProjectRef::DEFAULT, &run)
3936            .await
3937            .unwrap();
3938        assert_eq!(
3939            store
3940                .get_workflow_run(ProjectRef::DEFAULT, "etl", "r1")
3941                .await
3942                .unwrap()
3943                .unwrap(),
3944            run
3945        );
3946        assert_eq!(
3947            store
3948                .list_workflow_runs(ProjectRef::DEFAULT, "etl")
3949                .await
3950                .unwrap()
3951                .len(),
3952            1
3953        );
3954        // The run sub-key must not appear in the definition list.
3955        assert_eq!(
3956            store
3957                .list_workflows(ProjectRef::DEFAULT)
3958                .await
3959                .unwrap()
3960                .len(),
3961            1
3962        );
3963
3964        // Delete reports prior existence + is idempotent.
3965        assert!(store
3966            .delete_workflow(ProjectRef::DEFAULT, "etl")
3967            .await
3968            .unwrap());
3969        assert!(store
3970            .get_workflow(ProjectRef::DEFAULT, "etl")
3971            .await
3972            .unwrap()
3973            .is_none());
3974        assert!(!store
3975            .delete_workflow(ProjectRef::DEFAULT, "etl")
3976            .await
3977            .unwrap());
3978    }
3979
3980    #[tokio::test]
3981    async fn function_trigger_storage_round_trips() {
3982        use crate::function::{
3983            Function, FunctionConfig, FunctionTrigger, Lifecycle, Owner, TriggerKind,
3984        };
3985        use crate::kv::MemoryKv;
3986
3987        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
3988        let f = Function::new(
3989            "worker",
3990            Owner::Project("acme".into()),
3991            "hashA",
3992            FunctionConfig::default(),
3993            Lifecycle::Independent,
3994            1,
3995        );
3996        store.put_function(ProjectRef::DEFAULT, &f).await.unwrap();
3997        assert!(store
3998            .list_triggers(ProjectRef::DEFAULT, "worker")
3999            .await
4000            .unwrap()
4001            .is_empty());
4002
4003        let cron = FunctionTrigger {
4004            id: "tick".into(),
4005            kind: TriggerKind::Cron {
4006                schedule: "* * * * *".into(),
4007                overlap: Default::default(),
4008            },
4009            last_fired_minute: None,
4010        };
4011        store
4012            .put_trigger(ProjectRef::DEFAULT, "worker", &cron)
4013            .await
4014            .unwrap();
4015        let queue = FunctionTrigger {
4016            id: "jobs".into(),
4017            kind: TriggerKind::Queue {
4018                topic: "jobs".into(),
4019                group: String::new(),
4020                start: Default::default(),
4021            },
4022            last_fired_minute: None,
4023        };
4024        store
4025            .put_trigger(ProjectRef::DEFAULT, "worker", &queue)
4026            .await
4027            .unwrap();
4028
4029        assert_eq!(
4030            store
4031                .list_triggers(ProjectRef::DEFAULT, "worker")
4032                .await
4033                .unwrap()
4034                .len(),
4035            2
4036        );
4037        assert_eq!(
4038            store
4039                .get_trigger(ProjectRef::DEFAULT, "worker", "tick")
4040                .await
4041                .unwrap()
4042                .unwrap(),
4043            cron
4044        );
4045        // Trigger sub-keys don't leak into the function list.
4046        assert_eq!(
4047            store
4048                .list_stored_functions(ProjectRef::DEFAULT)
4049                .await
4050                .unwrap()
4051                .len(),
4052            1
4053        );
4054
4055        // Dedup state persists through an update.
4056        let mut fired = cron.clone();
4057        fired.last_fired_minute = Some(42);
4058        store
4059            .put_trigger(ProjectRef::DEFAULT, "worker", &fired)
4060            .await
4061            .unwrap();
4062        assert_eq!(
4063            store
4064                .get_trigger(ProjectRef::DEFAULT, "worker", "tick")
4065                .await
4066                .unwrap()
4067                .unwrap()
4068                .last_fired_minute,
4069            Some(42)
4070        );
4071
4072        // Delete reports prior existence + is idempotent.
4073        assert!(store
4074            .delete_trigger(ProjectRef::DEFAULT, "worker", "tick")
4075            .await
4076            .unwrap());
4077        assert!(!store
4078            .delete_trigger(ProjectRef::DEFAULT, "worker", "tick")
4079            .await
4080            .unwrap());
4081        assert_eq!(
4082            store
4083                .list_triggers(ProjectRef::DEFAULT, "worker")
4084                .await
4085                .unwrap()
4086                .len(),
4087            1
4088        );
4089    }
4090
4091    #[tokio::test]
4092    async fn function_metering_storage_is_tenant_isolated() {
4093        use crate::function::{Metering, MeteringSample};
4094        use crate::kv::MemoryKv;
4095
4096        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
4097        assert!(store
4098            .get_metering(ProjectRef::DEFAULT, "a")
4099            .await
4100            .unwrap()
4101            .is_none());
4102
4103        let mut ma = Metering::new("a");
4104        ma.record(
4105            &MeteringSample {
4106                success: true,
4107                duration_ms: 4,
4108                bytes_in: 1,
4109                bytes_out: 2,
4110            },
4111            10,
4112        );
4113        store.put_metering(ProjectRef::DEFAULT, &ma).await.unwrap();
4114
4115        let mut mb = Metering::new("b");
4116        mb.record(
4117            &MeteringSample {
4118                success: false,
4119                duration_ms: 9,
4120                bytes_in: 0,
4121                bytes_out: 0,
4122            },
4123            11,
4124        );
4125        store.put_metering(ProjectRef::DEFAULT, &mb).await.unwrap();
4126
4127        // Each function's aggregate is stored + read independently.
4128        assert_eq!(
4129            store
4130                .get_metering(ProjectRef::DEFAULT, "a")
4131                .await
4132                .unwrap()
4133                .unwrap()
4134                .successes,
4135            1
4136        );
4137        assert_eq!(
4138            store
4139                .get_metering(ProjectRef::DEFAULT, "b")
4140                .await
4141                .unwrap()
4142                .unwrap()
4143                .failures,
4144            1
4145        );
4146        let all = store.list_metering(ProjectRef::DEFAULT).await.unwrap();
4147        assert_eq!(all.len(), 2);
4148    }
4149
4150    #[tokio::test]
4151    async fn managed_dns_ledger_round_trip_and_retract() {
4152        use crate::dns_managed::{ManagedDns, ManagedRecord};
4153        use crate::kv::MemoryKv;
4154
4155        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
4156        assert!(store
4157            .get_managed_dns(
4158                ProjectRef::DEFAULT,
4159                &SiteName::new("blog"),
4160                "www.example.com"
4161            )
4162            .await
4163            .unwrap()
4164            .is_none());
4165
4166        let ledger = ManagedDns::new(
4167            "www.example.com",
4168            "cloudflare",
4169            vec![ManagedRecord {
4170                kind: "A".into(),
4171                name: "www.example.com".into(),
4172                value: "203.0.113.7".into(),
4173                ttl: 300,
4174            }],
4175            10,
4176        );
4177        store
4178            .set_managed_dns(ProjectRef::DEFAULT, &SiteName::new("blog"), &ledger)
4179            .await
4180            .unwrap();
4181        // Lookup normalizes the host, so a differently-cased/dotted query hits it.
4182        assert_eq!(
4183            store
4184                .get_managed_dns(
4185                    ProjectRef::DEFAULT,
4186                    &SiteName::new("blog"),
4187                    "WWW.example.com."
4188                )
4189                .await
4190                .unwrap(),
4191            Some(ledger.clone())
4192        );
4193        assert_eq!(
4194            store
4195                .list_managed_dns(ProjectRef::DEFAULT, &SiteName::new("blog"))
4196                .await
4197                .unwrap(),
4198            vec![ledger]
4199        );
4200
4201        store
4202            .remove_managed_dns(
4203                ProjectRef::DEFAULT,
4204                &SiteName::new("blog"),
4205                "www.example.com",
4206            )
4207            .await
4208            .unwrap();
4209        assert!(store
4210            .list_managed_dns(ProjectRef::DEFAULT, &SiteName::new("blog"))
4211            .await
4212            .unwrap()
4213            .is_empty());
4214    }
4215
4216    #[tokio::test]
4217    async fn site_config_round_trip_and_host_routing() {
4218        use crate::config::{DomainConfig, SiteConfig};
4219        use crate::kv::MemoryKv;
4220
4221        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
4222        let config = SiteConfig {
4223            domains: DomainConfig {
4224                primary: Some("example.com".into()),
4225                aliases: vec!["www.example.com".into()],
4226                wildcards: vec!["*.example.com".into()],
4227                ..Default::default()
4228            },
4229            ..Default::default()
4230        };
4231        store
4232            .set_site_config(ProjectRef::DEFAULT, "blog", &config)
4233            .await
4234            .unwrap();
4235
4236        let resolved = |host: &'static str| {
4237            let store = store.clone();
4238            async move {
4239                store
4240                    .resolve_site_by_host(host)
4241                    .await
4242                    .unwrap()
4243                    .map(|o| o.site)
4244            }
4245        };
4246        assert_eq!(resolved("example.com").await.as_deref(), Some("blog")); // exact primary
4247        assert_eq!(resolved("www.example.com").await.as_deref(), Some("blog")); // exact alias
4248        assert_eq!(resolved("api.example.com").await.as_deref(), Some("blog")); // wildcard
4249        assert_eq!(resolved("a.b.example.com").await.as_deref(), Some("blog")); // wildcard, deep
4250        assert_eq!(resolved("other.com").await, None);
4251
4252        // Clearing the domains drops the index entries.
4253        store
4254            .set_site_config(ProjectRef::DEFAULT, "blog", &SiteConfig::default())
4255            .await
4256            .unwrap();
4257        assert_eq!(resolved("example.com").await, None);
4258    }
4259
4260    /// The hot-path resolve cache memoizes **misses** as well as hits; a domain
4261    /// added *after* a host was first resolved (and cached as unmapped) must take
4262    /// effect — the domain-index write invalidates the negative entry. Guards the
4263    /// generation bump against serving a stale "not found" for a freshly-claimed host.
4264    #[tokio::test]
4265    async fn resolve_cache_invalidates_negative_entry_on_domain_add() {
4266        use crate::config::{DomainConfig, SiteConfig};
4267        use crate::kv::MemoryKv;
4268
4269        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
4270        // Prime the negative cache: this host maps to nothing yet.
4271        assert_eq!(
4272            store.resolve_site_by_host("shop.example").await.unwrap(),
4273            None
4274        );
4275        // Claim it for a site.
4276        store
4277            .set_site_config(
4278                ProjectRef::DEFAULT,
4279                "shop",
4280                &SiteConfig {
4281                    domains: DomainConfig {
4282                        primary: Some("shop.example".into()),
4283                        ..Default::default()
4284                    },
4285                    ..Default::default()
4286                },
4287            )
4288            .await
4289            .unwrap();
4290        // The stale "not found" must not be served — the write bumped the generation.
4291        assert_eq!(
4292            store
4293                .resolve_site_by_host("shop.example")
4294                .await
4295                .unwrap()
4296                .map(|o| o.site)
4297                .as_deref(),
4298            Some("shop")
4299        );
4300        // Removing the site frees the host again (delete_site also invalidates).
4301        store
4302            .delete_site(ProjectRef::DEFAULT, "shop")
4303            .await
4304            .unwrap();
4305        assert_eq!(
4306            store.resolve_site_by_host("shop.example").await.unwrap(),
4307            None
4308        );
4309    }
4310
4311    /// Multi-tenant wildcard-vhost precedence (regression pin). In ONE project, a **wildcard**
4312    /// site (`*.construens.com` → `portal`) coexists in the same suffix with an **exact** site
4313    /// (`console.construens.com` → `console`) and a per-tenant **exact** custom host on a third
4314    /// site (`vip.construens.com` → `vip`). The pins: exact always beats the wildcard, and the
4315    /// wildcard catches every un-attached tenant label at any depth.
4316    #[tokio::test]
4317    async fn wildcard_vhost_precedence_exact_beats_wildcard() {
4318        use crate::config::{DomainConfig, SiteConfig};
4319        use crate::kv::MemoryKv;
4320
4321        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
4322        let attach = |site: &'static str, domains: DomainConfig| {
4323            let store = store.clone();
4324            async move {
4325                store
4326                    .set_site_config(
4327                        ProjectRef::DEFAULT,
4328                        site,
4329                        &SiteConfig {
4330                            domains,
4331                            ..Default::default()
4332                        },
4333                    )
4334                    .await
4335                    .unwrap();
4336            }
4337        };
4338        // `portal` owns the wildcard for the whole suffix.
4339        attach(
4340            "portal",
4341            DomainConfig {
4342                wildcards: vec!["*.construens.com".into()],
4343                ..Default::default()
4344            },
4345        )
4346        .await;
4347        // `console` owns an EXACT host under the same suffix.
4348        attach(
4349            "console",
4350            DomainConfig {
4351                primary: Some("console.construens.com".into()),
4352                ..Default::default()
4353            },
4354        )
4355        .await;
4356        // A per-tenant custom EXACT host under the suffix, on a third site.
4357        attach(
4358            "vip",
4359            DomainConfig {
4360                primary: Some("vip.construens.com".into()),
4361                ..Default::default()
4362            },
4363        )
4364        .await;
4365
4366        let resolved = |host: &'static str| {
4367            let store = store.clone();
4368            async move {
4369                store
4370                    .resolve_site_by_host(host)
4371                    .await
4372                    .unwrap()
4373                    .map(|o| o.site)
4374            }
4375        };
4376        // Exact beats wildcard: an exactly-attached host wins over `*.construens.com`.
4377        assert_eq!(
4378            resolved("console.construens.com").await.as_deref(),
4379            Some("console")
4380        );
4381        assert_eq!(resolved("vip.construens.com").await.as_deref(), Some("vip"));
4382        // The wildcard catches every un-attached tenant label — at any depth.
4383        assert_eq!(
4384            resolved("tenant7.construens.com").await.as_deref(),
4385            Some("portal")
4386        );
4387        assert_eq!(
4388            resolved("anything-else.construens.com").await.as_deref(),
4389            Some("portal")
4390        );
4391        assert_eq!(
4392            resolved("deep.team.construens.com").await.as_deref(),
4393            Some("portal")
4394        );
4395        // A host outside the suffix has no claim (the bare suffix is not a sub-label match either).
4396        assert_eq!(resolved("construens.com").await, None);
4397        assert_eq!(resolved("console.example.com").await, None);
4398    }
4399
4400    /// A `*.`-host attaches and routes with **no real DNS** via the admin override (what
4401    /// `attach_domain_unverified` does: start a DNS challenge, mark it verified without a proof,
4402    /// attach) — so an operator can wire `*.construens.com` in a dev run and have every tenant
4403    /// label route immediately.
4404    #[tokio::test]
4405    async fn wildcard_attaches_and_routes_without_real_dns_admin_override() {
4406        use crate::domain_verify::VerificationMethod;
4407        use crate::kv::MemoryKv;
4408
4409        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
4410        let site = SiteName::new("portal");
4411        // The admin-override sequence, with no live DNS lookup anywhere: a wildcard needs the DNS
4412        // method, but the proof is asserted out-of-band (marked verified), then attached.
4413        store
4414            .start_domain_verification(
4415                ProjectRef::DEFAULT,
4416                &site,
4417                "*.construens.com",
4418                VerificationMethod::Dns,
4419                0,
4420            )
4421            .await
4422            .unwrap();
4423        store
4424            .mark_domain_verified(ProjectRef::DEFAULT, &site, "*.construens.com")
4425            .await
4426            .unwrap();
4427        store
4428            .attach_verified_domain(ProjectRef::DEFAULT, &site, "*.construens.com")
4429            .await
4430            .unwrap();
4431
4432        // It routes immediately: any tenant label under the suffix resolves to the portal site.
4433        assert_eq!(
4434            store
4435                .resolve_site_by_host("tenant7.construens.com")
4436                .await
4437                .unwrap()
4438                .map(|o| o.site)
4439                .as_deref(),
4440            Some("portal")
4441        );
4442        // The wildcard matches sub-labels only — the bare suffix is not claimed.
4443        assert_eq!(
4444            store
4445                .resolve_site_by_host("construens.com")
4446                .await
4447                .unwrap()
4448                .map(|o| o.site),
4449            None
4450        );
4451    }
4452
4453    #[tokio::test]
4454    async fn site_config_is_content_addressed_and_dedups() {
4455        use crate::config::SiteConfig;
4456        use crate::kv::MemoryKv;
4457
4458        let kv = Arc::new(MemoryKv::new());
4459        let store = DeployStore::new(Arc::new(NullStorage), kv.clone());
4460
4461        // Distinguish the sites by a *non-domain* field: two sites can't share a
4462        // domain (the host-uniqueness guard refuses it), but they can share an
4463        // identical body — which is what this test is about.
4464        let mut cfg = SiteConfig::default();
4465        cfg.security.https_redirect = true;
4466        // Two different sites with identical config dedup to one body blob.
4467        store
4468            .set_site_config(ProjectRef::DEFAULT, "s1", &cfg)
4469            .await
4470            .unwrap();
4471        let mut cfg2 = cfg.clone();
4472        cfg2.security.https_redirect = false;
4473        store
4474            .set_site_config(ProjectRef::DEFAULT, "s2", &cfg2)
4475            .await
4476            .unwrap();
4477        store
4478            .set_site_config(ProjectRef::DEFAULT, "s3", &cfg)
4479            .await
4480            .unwrap(); // identical to s1
4481
4482        // Pointers exist for each site; bodies are deduped (s1 == s3 → one blob).
4483        let bodies = kv.list_prefix("siteconfig/").await.unwrap();
4484        assert_eq!(bodies.len(), 2, "s1/s3 share a body; s2 distinct");
4485        let pointers = kv.list_prefix("project/default/site/").await.unwrap();
4486        assert_eq!(pointers.len(), 3);
4487
4488        // Round-trips.
4489        assert!(
4490            store
4491                .get_site_config(ProjectRef::DEFAULT, "s1")
4492                .await
4493                .unwrap()
4494                .unwrap()
4495                .security
4496                .https_redirect
4497        );
4498        assert_eq!(
4499            store
4500                .get_site_config(ProjectRef::DEFAULT, "missing")
4501                .await
4502                .unwrap(),
4503            None
4504        );
4505
4506        // Editing s1 flips its pointer and orphans its old body; GC reclaims it
4507        // (s3 still references it, so it survives until s3 changes too).
4508        let mut edited = cfg.clone();
4509        edited.security.frame_options = Some("DENY".into());
4510        store
4511            .set_site_config(ProjectRef::DEFAULT, "s1", &edited)
4512            .await
4513            .unwrap();
4514        store.collect_garbage(true).await.unwrap();
4515        // s1's old body is still referenced by s3 → not collected.
4516        assert_eq!(kv.list_prefix("siteconfig/").await.unwrap().len(), 3);
4517        // Now change s3 too; the old shared body becomes orphaned and is GC'd.
4518        store
4519            .set_site_config(ProjectRef::DEFAULT, "s3", &edited)
4520            .await
4521            .unwrap();
4522        store.collect_garbage(true).await.unwrap();
4523        let remaining = kv.list_prefix("siteconfig/").await.unwrap();
4524        assert_eq!(remaining.len(), 2, "orphaned shared body reclaimed");
4525        // Everything still reads correctly after GC.
4526        assert!(
4527            store
4528                .get_site_config(ProjectRef::DEFAULT, "s1")
4529                .await
4530                .unwrap()
4531                .unwrap()
4532                .security
4533                .https_redirect
4534        );
4535        assert!(
4536            !store
4537                .get_site_config(ProjectRef::DEFAULT, "s2")
4538                .await
4539                .unwrap()
4540                .unwrap()
4541                .security
4542                .https_redirect
4543        );
4544    }
4545
4546    #[tokio::test]
4547    async fn cert_status_lists_domains_and_expiry_without_keys() {
4548        use crate::cert::StoredCert;
4549        use crate::kv::MemoryKv;
4550
4551        let kv = Arc::new(MemoryKv::new());
4552        let store = DeployStore::new(Arc::new(NullStorage), kv.clone());
4553        // Two stored certs (as the cluster cert store writes them) + an unrelated key.
4554        for (domain, not_after) in [("b.example.com", 2000u64), ("a.example.com", 1000u64)] {
4555            let cert = StoredCert::new("CHAINPEM", "KEYPEM", not_after);
4556            kv.put(
4557                &crate::cert::cert_key(domain),
4558                serde_json::to_vec(&cert).unwrap(),
4559            )
4560            .await
4561            .unwrap();
4562        }
4563        kv.put("site/x/config", b"{}".to_vec()).await.unwrap();
4564
4565        let status = store.cert_status().await.unwrap();
4566        assert_eq!(status.len(), 2);
4567        // Sorted by domain; carries expiry, never key material.
4568        assert_eq!(status[0].domain, "a.example.com");
4569        assert_eq!(status[0].not_after_unix, 1000);
4570        assert_eq!(status[1].domain, "b.example.com");
4571    }
4572
4573    #[tokio::test]
4574    async fn domain_verification_gates_attachment() {
4575        use crate::domain_verify::VerificationMethod;
4576
4577        let store = store();
4578
4579        // Start a challenge; re-starting under the same method is idempotent.
4580        let v1 = store
4581            .start_domain_verification(
4582                ProjectRef::DEFAULT,
4583                &SiteName::new("blog"),
4584                "example.com",
4585                VerificationMethod::Dns,
4586                100,
4587            )
4588            .await
4589            .unwrap();
4590        let v2 = store
4591            .start_domain_verification(
4592                ProjectRef::DEFAULT,
4593                &SiteName::new("blog"),
4594                "example.com",
4595                VerificationMethod::Dns,
4596                200,
4597            )
4598            .await
4599            .unwrap();
4600        assert_eq!(v1.token, v2.token, "same method → same pending token");
4601        assert!(!store
4602            .is_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
4603            .await
4604            .unwrap());
4605
4606        // Unverified hosts cannot be attached — the gate.
4607        assert!(store
4608            .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
4609            .await
4610            .is_err());
4611
4612        // Verify, then attach: the host enters routing as the primary.
4613        store
4614            .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
4615            .await
4616            .unwrap();
4617        assert!(store
4618            .is_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
4619            .await
4620            .unwrap());
4621        store
4622            .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
4623            .await
4624            .unwrap();
4625        assert_eq!(
4626            store
4627                .resolve_site_by_host("example.com")
4628                .await
4629                .unwrap()
4630                .map(|o| o.site)
4631                .as_deref(),
4632            Some("blog")
4633        );
4634        // A second verified host becomes an alias, not the primary.
4635        store
4636            .start_domain_verification(
4637                ProjectRef::DEFAULT,
4638                &SiteName::new("blog"),
4639                "www.example.com",
4640                VerificationMethod::Http,
4641                300,
4642            )
4643            .await
4644            .unwrap();
4645        store
4646            .mark_domain_verified(
4647                ProjectRef::DEFAULT,
4648                &SiteName::new("blog"),
4649                "www.example.com",
4650            )
4651            .await
4652            .unwrap();
4653        let config = store
4654            .attach_verified_domain(
4655                ProjectRef::DEFAULT,
4656                &SiteName::new("blog"),
4657                "www.example.com",
4658            )
4659            .await
4660            .unwrap();
4661        assert_eq!(config.domains.primary.as_deref(), Some("example.com"));
4662        assert_eq!(config.domains.aliases, vec!["www.example.com".to_string()]);
4663
4664        // A wildcard is verified at its base name and attached as a wildcard.
4665        store
4666            .start_domain_verification(
4667                ProjectRef::DEFAULT,
4668                &SiteName::new("blog"),
4669                "*.example.com",
4670                VerificationMethod::Dns,
4671                400,
4672            )
4673            .await
4674            .unwrap();
4675        // The challenge keys on the base host, so the wildcard shares it.
4676        assert!(store
4677            .is_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "*.example.com")
4678            .await
4679            .unwrap());
4680        let config = store
4681            .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("blog"), "*.example.com")
4682            .await
4683            .unwrap();
4684        assert_eq!(config.domains.wildcards, vec!["*.example.com".to_string()]);
4685
4686        // Listing surfaces every challenge; removing drops the record.
4687        assert_eq!(
4688            store
4689                .list_domain_verifications(ProjectRef::DEFAULT, &SiteName::new("blog"))
4690                .await
4691                .unwrap()
4692                .len(),
4693            2
4694        );
4695        assert!(store
4696            .remove_domain_verification(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
4697            .await
4698            .unwrap());
4699        assert!(!store
4700            .is_domain_verified(ProjectRef::DEFAULT, &SiteName::new("blog"), "example.com")
4701            .await
4702            .unwrap());
4703    }
4704
4705    /// The host-uniqueness hijack guard: a host already routed to one site cannot
4706    /// be claimed by another — neither via `attach_verified_domain` nor via a
4707    /// direct `set_site_config` — so a site-writer can't steal another's domain.
4708    #[tokio::test]
4709    async fn host_cannot_be_hijacked_across_sites() {
4710        use crate::config::{DomainConfig, SiteConfig};
4711        use crate::domain_verify::VerificationMethod;
4712
4713        let store = store();
4714
4715        // Site `a` legitimately verifies + attaches `shared.example`.
4716        store
4717            .start_domain_verification(
4718                ProjectRef::DEFAULT,
4719                &SiteName::new("a"),
4720                "shared.example",
4721                VerificationMethod::Http,
4722                100,
4723            )
4724            .await
4725            .unwrap();
4726        store
4727            .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("a"), "shared.example")
4728            .await
4729            .unwrap();
4730        store
4731            .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("a"), "shared.example")
4732            .await
4733            .unwrap();
4734        assert_eq!(
4735            store
4736                .resolve_site_by_host("shared.example")
4737                .await
4738                .unwrap()
4739                .map(|o| o.site)
4740                .as_deref(),
4741            Some("a")
4742        );
4743
4744        // Site `b` verifies the same host (imagine control briefly changed hands,
4745        // or a stale challenge) and tries to attach — it must be refused, not
4746        // silently steal the live mapping.
4747        store
4748            .start_domain_verification(
4749                ProjectRef::DEFAULT,
4750                &SiteName::new("b"),
4751                "shared.example",
4752                VerificationMethod::Http,
4753                200,
4754            )
4755            .await
4756            .unwrap();
4757        store
4758            .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("b"), "shared.example")
4759            .await
4760            .unwrap();
4761        let err = store
4762            .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("b"), "shared.example")
4763            .await
4764            .expect_err("second site must not hijack an attached host");
4765        assert!(matches!(err, DeployError::Conflict(_)), "got {err:?}");
4766
4767        // The direct config path is guarded too (this is what a raw
4768        // `PUT /config` with a stolen domain would hit).
4769        let stolen = SiteConfig {
4770            domains: DomainConfig {
4771                primary: Some("shared.example".into()),
4772                ..Default::default()
4773            },
4774            ..Default::default()
4775        };
4776        let err = store
4777            .set_site_config(ProjectRef::DEFAULT, "b", &stolen)
4778            .await
4779            .expect_err("set_site_config must refuse another site's host");
4780        assert!(matches!(err, DeployError::Conflict(_)), "got {err:?}");
4781
4782        // The original owner is untouched.
4783        assert_eq!(
4784            store
4785                .resolve_site_by_host("shared.example")
4786                .await
4787                .unwrap()
4788                .map(|o| o.site)
4789                .as_deref(),
4790            Some("a")
4791        );
4792
4793        // Re-writing the *same* site's own config (no owner change) is fine — the
4794        // guard only fires across sites.
4795        let readd = SiteConfig {
4796            domains: DomainConfig {
4797                primary: Some("shared.example".into()),
4798                aliases: vec!["www.shared.example".into()],
4799                ..Default::default()
4800            },
4801            ..Default::default()
4802        };
4803        store
4804            .set_site_config(ProjectRef::DEFAULT, "a", &readd)
4805            .await
4806            .unwrap();
4807        assert_eq!(
4808            store
4809                .resolve_site_by_host("www.shared.example")
4810                .await
4811                .unwrap()
4812                .map(|o| o.site)
4813                .as_deref(),
4814            Some("a")
4815        );
4816    }
4817
4818    /// The declarative domain tenant source (Stage 0): a per-host `contexts` tag is written into
4819    /// the routing index and resolved back, an exact alias inherits the primary's tag, and a
4820    /// wildcard carries its own — so one deployment serves many storefronts, each domain a tenant.
4821    #[tokio::test]
4822    async fn domain_context_tag_is_written_and_inherited() {
4823        use crate::config::{DomainConfig, SiteConfig};
4824
4825        let store = store();
4826        let cfg = SiteConfig {
4827            domains: DomainConfig {
4828                primary: Some("acme-store.com".into()),
4829                aliases: vec!["www.acme-store.com".into()],
4830                wildcards: vec!["*.globex-store.com".into()],
4831                contexts: std::collections::BTreeMap::from([
4832                    ("acme-store.com".into(), "acme".into()),
4833                    ("*.globex-store.com".into(), "globex".into()),
4834                ]),
4835                ..Default::default()
4836            },
4837            ..Default::default()
4838        };
4839        store
4840            .set_site_config(ProjectRef::DEFAULT, "shop", &cfg)
4841            .await
4842            .unwrap();
4843
4844        async fn ctx(store: &DeployStore, host: &str) -> Option<String> {
4845            store
4846                .resolve_site_by_host(host)
4847                .await
4848                .unwrap()
4849                .and_then(|o| o.context)
4850        }
4851        // The primary carries its tag; the alias inherits the primary's; a subdomain inherits the
4852        // wildcard's — three distinct hosts, the right tenant on each.
4853        assert_eq!(ctx(&store, "acme-store.com").await.as_deref(), Some("acme"));
4854        assert_eq!(
4855            ctx(&store, "www.acme-store.com").await.as_deref(),
4856            Some("acme")
4857        );
4858        assert_eq!(
4859            ctx(&store, "tenant7.globex-store.com").await.as_deref(),
4860            Some("globex")
4861        );
4862    }
4863
4864    /// The hijack guard folds case + trailing dot: a variant-cased or
4865    /// dot-suffixed host can't write a second routing key past the guard, and
4866    /// routing resolves any casing to the one owner.
4867    #[tokio::test]
4868    async fn host_uniqueness_is_case_and_dot_insensitive() {
4869        use crate::config::{DomainConfig, SiteConfig};
4870        use crate::domain_verify::VerificationMethod;
4871
4872        let store = store();
4873        // Site `a` legitimately attaches `example.com`.
4874        store
4875            .start_domain_verification(
4876                ProjectRef::DEFAULT,
4877                &SiteName::new("a"),
4878                "example.com",
4879                VerificationMethod::Http,
4880                100,
4881            )
4882            .await
4883            .unwrap();
4884        store
4885            .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("a"), "example.com")
4886            .await
4887            .unwrap();
4888        store
4889            .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("a"), "example.com")
4890            .await
4891            .unwrap();
4892
4893        // Site `b` tries to claim case / trailing-dot variants of the same host.
4894        for variant in ["Example.COM", "example.com.", "EXAMPLE.com."] {
4895            let cfg = SiteConfig {
4896                domains: DomainConfig {
4897                    primary: Some(variant.into()),
4898                    ..Default::default()
4899                },
4900                ..Default::default()
4901            };
4902            let err = store
4903                .set_site_config(ProjectRef::DEFAULT, "b", &cfg)
4904                .await
4905                .expect_err("variant claim must be refused");
4906            assert!(
4907                matches!(err, DeployError::Conflict(_)),
4908                "variant {variant:?} must Conflict, got {err:?}"
4909            );
4910        }
4911
4912        // Routing folds case + trailing dot to the one owner.
4913        for h in ["example.com", "Example.com", "EXAMPLE.COM", "example.com."] {
4914            assert_eq!(
4915                store
4916                    .resolve_site_by_host(h)
4917                    .await
4918                    .unwrap()
4919                    .map(|o| o.site)
4920                    .as_deref(),
4921                Some("a"),
4922                "host {h:?} must resolve to site a"
4923            );
4924        }
4925    }
4926
4927    /// The self-serve edge lookup: a pending HTTP challenge is found by
4928    /// (host, token), and only then — not for the wrong token/host, a DNS
4929    /// challenge, or an expired one.
4930    #[tokio::test]
4931    async fn self_serve_challenge_lookup_matches_pending_http_only() {
4932        use crate::domain_verify::{VerificationMethod, CHALLENGE_TTL_SECS};
4933
4934        let store = store();
4935        let v = store
4936            .start_domain_verification(
4937                ProjectRef::DEFAULT,
4938                &SiteName::new("docs"),
4939                "docs.example",
4940                VerificationMethod::Http,
4941                1_000,
4942            )
4943            .await
4944            .unwrap();
4945
4946        // Exact (host, token) within the TTL → found.
4947        let found = store
4948            .find_pending_http_challenge("docs.example", &v.token, 1_000)
4949            .await
4950            .unwrap();
4951        assert_eq!(
4952            found.as_ref().map(|f| f.token.clone()),
4953            Some(v.token.clone())
4954        );
4955        // A trailing dot / uppercase host still normalizes to a match.
4956        assert!(store
4957            .find_pending_http_challenge("Docs.Example.", &v.token, 1_000)
4958            .await
4959            .unwrap()
4960            .is_some());
4961
4962        // Wrong token, wrong host → no match (never leaks another host's token).
4963        assert!(store
4964            .find_pending_http_challenge("docs.example", "not-the-token", 1_000)
4965            .await
4966            .unwrap()
4967            .is_none());
4968        assert!(store
4969            .find_pending_http_challenge("other.example", &v.token, 1_000)
4970            .await
4971            .unwrap()
4972            .is_none());
4973
4974        // Past the TTL → refused (a stale token can't be redeemed forever).
4975        assert!(store
4976            .find_pending_http_challenge("docs.example", &v.token, 1_000 + CHALLENGE_TTL_SECS + 1)
4977            .await
4978            .unwrap()
4979            .is_none());
4980
4981        // A DNS-method challenge is never served over the HTTP edge route.
4982        let dv = store
4983            .start_domain_verification(
4984                ProjectRef::DEFAULT,
4985                &SiteName::new("dns-site"),
4986                "dns.example",
4987                VerificationMethod::Dns,
4988                1_000,
4989            )
4990            .await
4991            .unwrap();
4992        assert!(store
4993            .find_pending_http_challenge("dns.example", &dv.token, 1_000)
4994            .await
4995            .unwrap()
4996            .is_none());
4997    }
4998
4999    /// A wildcard can only be attached with DNS proof; an HTTP token at the base
5000    /// host is refused. And a stale HTTP self-serve index entry (left by a later
5001    /// method change) never serves the wrong challenge — the lookup re-validates.
5002    #[tokio::test]
5003    async fn wildcard_requires_dns_and_stale_index_is_safe() {
5004        use crate::domain_verify::VerificationMethod;
5005
5006        let store = store();
5007
5008        // HTTP-verify the base host, then try to attach the wildcard → refused.
5009        let http = store
5010            .start_domain_verification(
5011                ProjectRef::DEFAULT,
5012                &SiteName::new("s"),
5013                "*.example.com",
5014                VerificationMethod::Http,
5015                100,
5016            )
5017            .await
5018            .unwrap();
5019        store
5020            .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
5021            .await
5022            .unwrap();
5023        let err = store
5024            .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
5025            .await
5026            .expect_err("wildcard with only HTTP proof must be refused");
5027        assert!(matches!(err, DeployError::Conflict(_)), "got {err:?}");
5028
5029        // Drop it and re-verify via DNS → the wildcard now attaches. This replaces
5030        // the record (the old HTTP token's self-serve index entry is dropped on
5031        // remove), and re-proves via DNS.
5032        store
5033            .remove_domain_verification(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
5034            .await
5035            .unwrap();
5036        // The removed HTTP token is no longer self-servable.
5037        assert!(store
5038            .find_pending_http_challenge("example.com", &http.token, 100)
5039            .await
5040            .unwrap()
5041            .is_none());
5042        store
5043            .start_domain_verification(
5044                ProjectRef::DEFAULT,
5045                &SiteName::new("s"),
5046                "*.example.com",
5047                VerificationMethod::Dns,
5048                200,
5049            )
5050            .await
5051            .unwrap();
5052        store
5053            .mark_domain_verified(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
5054            .await
5055            .unwrap();
5056        let cfg = store
5057            .attach_verified_domain(ProjectRef::DEFAULT, &SiteName::new("s"), "*.example.com")
5058            .await
5059            .unwrap();
5060        assert_eq!(cfg.domains.wildcards, vec!["*.example.com".to_string()]);
5061
5062        // Stale-index safety: an HTTP challenge whose record is later switched to
5063        // DNS (without removal) leaves a dangling token index; the lookup loads
5064        // the current (DNS) record and refuses to serve the old HTTP token.
5065        let h2 = store
5066            .start_domain_verification(
5067                ProjectRef::DEFAULT,
5068                &SiteName::new("s2"),
5069                "host.example",
5070                VerificationMethod::Http,
5071                300,
5072            )
5073            .await
5074            .unwrap();
5075        assert!(store
5076            .find_pending_http_challenge("host.example", &h2.token, 300)
5077            .await
5078            .unwrap()
5079            .is_some());
5080        store
5081            .start_domain_verification(
5082                ProjectRef::DEFAULT,
5083                &SiteName::new("s2"),
5084                "host.example",
5085                VerificationMethod::Dns,
5086                300,
5087            )
5088            .await
5089            .unwrap();
5090        assert!(
5091            store
5092                .find_pending_http_challenge("host.example", &h2.token, 300)
5093                .await
5094                .unwrap()
5095                .is_none(),
5096            "a stale HTTP index must not serve a token whose record is now DNS"
5097        );
5098    }
5099
5100    #[tokio::test]
5101    async fn pending_verifications_are_capped_per_site() {
5102        let store = store();
5103        // Seed the cap's worth of distinct pending hosts — all accepted.
5104        for i in 0..64 {
5105            store
5106                .start_domain_verification(
5107                    ProjectRef::DEFAULT,
5108                    &SiteName::new("site"),
5109                    &format!("h{i}.example"),
5110                    VerificationMethod::Http,
5111                    100,
5112                )
5113                .await
5114                .unwrap();
5115        }
5116        // One more genuinely-new host is refused (bounds the reconcile fan-out).
5117        let err = store
5118            .start_domain_verification(
5119                ProjectRef::DEFAULT,
5120                &SiteName::new("site"),
5121                "overflow.example",
5122                VerificationMethod::Http,
5123                100,
5124            )
5125            .await
5126            .expect_err("the 65th pending host must be rejected");
5127        assert!(matches!(err, DeployError::Conflict(_)), "got {err:?}");
5128        // Re-running an EXISTING host still works (returns early before the cap).
5129        store
5130            .start_domain_verification(
5131                ProjectRef::DEFAULT,
5132                &SiteName::new("site"),
5133                "h0.example",
5134                VerificationMethod::Http,
5135                100,
5136            )
5137            .await
5138            .expect("re-running an existing challenge is not capped");
5139        // A different site has its own budget.
5140        store
5141            .start_domain_verification(
5142                ProjectRef::DEFAULT,
5143                &SiteName::new("other"),
5144                "fresh.example",
5145                VerificationMethod::Http,
5146                100,
5147            )
5148            .await
5149            .expect("a different site is unaffected");
5150    }
5151
5152    fn store() -> DeployStore {
5153        use crate::kv::MemoryKv;
5154        DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()))
5155    }
5156
5157    #[tokio::test]
5158    async fn default_project_is_visible_on_a_fresh_store_and_ensure_is_idempotent() {
5159        let s = store();
5160        let default = crate::project::DEFAULT_PROJECT;
5161
5162        // Reader backstop: even before any write, `default` resolves and lists.
5163        assert!(
5164            s.get_project(default).await.unwrap().is_some(),
5165            "`project show default` must never 404, even on a fresh store"
5166        );
5167        let listed = s.list_projects().await.unwrap();
5168        assert_eq!(
5169            listed.iter().filter(|p| p.name == default).count(),
5170            1,
5171            "`project ls` must show exactly one `default` on a fresh store"
5172        );
5173        // A non-existent project is still absent (the backstop is default-only).
5174        assert!(s.get_project("nope").await.unwrap().is_none());
5175
5176        // project_exists (the middleware's ghost guard): default always exists;
5177        // an uncreated project does not, so a write to it is rejected upstream.
5178        assert!(s.project_exists(default).await.unwrap());
5179        assert!(!s.project_exists("nope").await.unwrap());
5180
5181        // Boot ensure materializes the record once, then is a no-op.
5182        assert!(
5183            s.ensure_default_project().await.unwrap(),
5184            "first ensure creates"
5185        );
5186        assert!(
5187            !s.ensure_default_project().await.unwrap(),
5188            "second ensure is idempotent (presence-checked)"
5189        );
5190
5191        // After materialization the record is real (not the backstop) and still unique.
5192        let listed = s.list_projects().await.unwrap();
5193        assert_eq!(
5194            listed.iter().filter(|p| p.name == default).count(),
5195            1,
5196            "materializing `default` must not duplicate it in the listing"
5197        );
5198        assert_eq!(s.get_project(default).await.unwrap().unwrap().name, default);
5199    }
5200
5201    #[tokio::test]
5202    async fn daemon_config_store_round_trips_and_rolls_back() {
5203        use crate::daemon_config::DaemonConfig;
5204        let s = store();
5205        // None set → baseline.
5206        assert!(s.get_daemon_config().await.unwrap().is_none());
5207        assert!(s.daemon_config_generation().await.unwrap().is_none());
5208
5209        // Set gen 1.
5210        let g1cfg = DaemonConfig {
5211            default_site: Some("one".into()),
5212            ..Default::default()
5213        };
5214        let g1 = s.set_daemon_config(&g1cfg).await.unwrap();
5215        assert_eq!(
5216            s.daemon_config_generation().await.unwrap().as_deref(),
5217            Some(g1.as_str())
5218        );
5219        assert_eq!(s.get_daemon_config().await.unwrap().unwrap(), g1cfg);
5220        assert!(s.daemon_config_history().await.unwrap().is_empty());
5221
5222        // Set gen 2 → gen 1 goes to history.
5223        let g2cfg = DaemonConfig {
5224            default_site: Some("two".into()),
5225            ..Default::default()
5226        };
5227        let g2 = s.set_daemon_config(&g2cfg).await.unwrap();
5228        assert_ne!(g1, g2);
5229        assert_eq!(s.daemon_config_history().await.unwrap(), vec![g1.clone()]);
5230
5231        // Rollback → back to gen 1.
5232        let rolled = s.rollback_daemon_config().await.unwrap();
5233        assert_eq!(rolled.as_deref(), Some(g1.as_str()));
5234        assert_eq!(
5235            s.daemon_config_generation().await.unwrap().as_deref(),
5236            Some(g1.as_str())
5237        );
5238        assert_eq!(s.get_daemon_config().await.unwrap().unwrap(), g1cfg);
5239        // No further history → rollback is a no-op signal.
5240        assert!(s.rollback_daemon_config().await.unwrap().is_none());
5241    }
5242
5243    #[tokio::test]
5244    async fn compute_store_round_trips() {
5245        use crate::compute::{
5246            ComputeSpec, ComputeWorkload, PlacementConstraints, RestartPolicy, RootSource,
5247        };
5248        let s = store();
5249        let spec = ComputeSpec {
5250            version: crate::SCHEMA_VERSION,
5251            root: RootSource::Rootfs("r".repeat(64)),
5252            kernel: "k".repeat(64),
5253            kernel_cmdline: None,
5254            vcpus: 1,
5255            mem_mib: 256,
5256            entrypoint: vec!["/app".into()],
5257            env: Default::default(),
5258            port: 8080,
5259            restart: RestartPolicy::Always,
5260            startup_grace_secs: 30,
5261            scale_to_zero: false,
5262            volumes: vec![],
5263            writable_root: false,
5264            cap_add: Vec::new(),
5265            user: None,
5266            isolation: Default::default(),
5267            prefer_backend: None,
5268            bindings: vec![],
5269        };
5270        // Content-addressed spec: storing returns the hash; re-reads match.
5271        let hash = s.put_compute_spec(&spec).await.unwrap();
5272        assert_eq!(hash, spec.id());
5273        assert_eq!(s.get_compute_spec(&hash).await.unwrap(), Some(spec));
5274        assert!(s.get_compute_spec("deadbeef").await.unwrap().is_none());
5275
5276        // Workload desired state: set / get / list / delete.
5277        let workload = ComputeWorkload {
5278            version: crate::SCHEMA_VERSION,
5279            name: "api".into(),
5280            active: hash.clone(),
5281            replicas: 3,
5282            placement: PlacementConstraints::default(),
5283        };
5284        s.set_compute_workload(ProjectRef::DEFAULT, &workload)
5285            .await
5286            .unwrap();
5287        assert_eq!(
5288            s.get_compute_workload(ProjectRef::DEFAULT, "api")
5289                .await
5290                .unwrap(),
5291            Some(workload)
5292        );
5293        assert_eq!(
5294            s.list_compute_workloads(ProjectRef::DEFAULT)
5295                .await
5296                .unwrap()
5297                .len(),
5298            1
5299        );
5300        assert!(s
5301            .delete_compute_workload(ProjectRef::DEFAULT, "api")
5302            .await
5303            .unwrap());
5304        assert!(!s
5305            .delete_compute_workload(ProjectRef::DEFAULT, "api")
5306            .await
5307            .unwrap());
5308        assert!(s
5309            .list_compute_workloads(ProjectRef::DEFAULT)
5310            .await
5311            .unwrap()
5312            .is_empty());
5313    }
5314
5315    /// A distinct, blob-free manifest (distinguished only by its config), so it
5316    /// activates without a real blob backend.
5317    fn empty_manifest(clean_urls: bool) -> Manifest {
5318        Manifest {
5319            config: DeployConfig {
5320                clean_urls,
5321                ..DeployConfig::default()
5322            },
5323            ..Default::default()
5324        }
5325    }
5326
5327    #[tokio::test]
5328    async fn deploy_meta_records_sizes_and_merges_provenance() {
5329        let store = store();
5330        let mut manifest = Manifest::default();
5331        manifest.files.insert("index.html".into(), {
5332            let mut e = entry("aa");
5333            e.size = 10;
5334            e
5335        });
5336        manifest.files.insert("app.js".into(), {
5337            let mut e = entry("bb");
5338            e.size = 32;
5339            e
5340        });
5341
5342        // First store carries provenance.
5343        let id = store
5344            .put_manifest_with(
5345                &manifest,
5346                DeployMetaInput {
5347                    source: Some("abc123".into()),
5348                    message: Some("first".into()),
5349                    ..Default::default()
5350                },
5351            )
5352            .await
5353            .unwrap();
5354        let meta = store.get_meta(&id).await.unwrap().unwrap();
5355        assert_eq!(meta.file_count, 2);
5356        assert_eq!(meta.total_size, 42);
5357        assert_eq!(meta.source.as_deref(), Some("abc123"));
5358        let created = meta.created_at;
5359
5360        // Re-store with empty input preserves created_at and prior provenance.
5361        store
5362            .put_manifest_with(&manifest, DeployMetaInput::default())
5363            .await
5364            .unwrap();
5365        let meta = store.get_meta(&id).await.unwrap().unwrap();
5366        assert_eq!(meta.created_at, created);
5367        assert_eq!(meta.source.as_deref(), Some("abc123"));
5368        assert_eq!(meta.message.as_deref(), Some("first"));
5369    }
5370
5371    #[tokio::test]
5372    async fn aliases_round_trip_and_guard_completeness() {
5373        let store = store();
5374        let manifest = empty_manifest(false);
5375        let id = store.put_manifest(&manifest).await.unwrap();
5376
5377        // Unknown deployment cannot be aliased.
5378        assert!(matches!(
5379            store
5380                .set_alias(ProjectRef::DEFAULT, "blog", "staging", "deadbeef")
5381                .await,
5382            Err(DeployError::NotFound(_))
5383        ));
5384
5385        store
5386            .set_alias(ProjectRef::DEFAULT, "blog", "staging", &id)
5387            .await
5388            .unwrap();
5389        assert_eq!(
5390            store
5391                .get_alias(ProjectRef::DEFAULT, "blog", "staging")
5392                .await
5393                .unwrap(),
5394            Some(id.clone())
5395        );
5396        let aliases = store
5397            .list_aliases(ProjectRef::DEFAULT, "blog")
5398            .await
5399            .unwrap();
5400        assert_eq!(aliases.get("staging"), Some(&id));
5401
5402        assert!(store
5403            .remove_alias(ProjectRef::DEFAULT, "blog", "staging")
5404            .await
5405            .unwrap());
5406        assert!(!store
5407            .remove_alias(ProjectRef::DEFAULT, "blog", "staging")
5408            .await
5409            .unwrap());
5410        assert_eq!(
5411            store
5412                .get_alias(ProjectRef::DEFAULT, "blog", "staging")
5413                .await
5414                .unwrap(),
5415            None
5416        );
5417    }
5418
5419    #[tokio::test]
5420    async fn retention_keep_last_collects_older_history() {
5421        let store = store();
5422        // Three distinct deployments, activated oldest→newest.
5423        let m1 = empty_manifest(false);
5424        let m2 = empty_manifest(true);
5425        let mut m3 = empty_manifest(true);
5426        m3.config.trailing_slash = crate::config::TrailingSlash::Always;
5427        let id1 = store.put_manifest(&m1).await.unwrap();
5428        let id2 = store.put_manifest(&m2).await.unwrap();
5429        let id3 = store.put_manifest(&m3).await.unwrap();
5430        store
5431            .activate(ProjectRef::DEFAULT, "blog", &id1)
5432            .await
5433            .unwrap();
5434        store
5435            .activate(ProjectRef::DEFAULT, "blog", &id2)
5436            .await
5437            .unwrap();
5438        store
5439            .activate(ProjectRef::DEFAULT, "blog", &id3)
5440            .await
5441            .unwrap();
5442
5443        // Default: full history kept, nothing collectable.
5444        let report = store.collect_garbage(false).await.unwrap();
5445        assert_eq!(report.manifests_removed, 0);
5446
5447        // keep_last = 1 keeps only the most recent (== current id3); id1 and id2
5448        // become orphans. An alias to id1 rescues it.
5449        store
5450            .set_alias(ProjectRef::DEFAULT, "blog", "pinned", &id1)
5451            .await
5452            .unwrap();
5453        let report = store
5454            .collect_garbage_with(
5455                false,
5456                GcOptions {
5457                    keep_last: Some(1),
5458                    ..Default::default()
5459                },
5460            )
5461            .await
5462            .unwrap();
5463        assert_eq!(report.manifests_removed, 1); // only id2 (id1 aliased, id3 current)
5464    }
5465
5466    #[tokio::test]
5467    async fn grace_window_protects_in_flight_manifest() {
5468        let store = store();
5469        // An uploaded-but-never-activated manifest: an orphan.
5470        let id = store.put_manifest(&empty_manifest(false)).await.unwrap();
5471        assert!(store.get_manifest(&id).await.unwrap().is_some());
5472
5473        // With a grace window it is protected (treated as in-flight)...
5474        let report = store
5475            .collect_garbage_with(
5476                false,
5477                GcOptions {
5478                    grace_secs: 3600,
5479                    ..Default::default()
5480                },
5481            )
5482            .await
5483            .unwrap();
5484        assert_eq!(report.manifests_removed, 0);
5485
5486        // ...without one, it is collectable.
5487        let report = store.collect_garbage(false).await.unwrap();
5488        assert_eq!(report.manifests_removed, 1);
5489    }
5490
5491    #[tokio::test]
5492    async fn resolve_manifest_id_exact_prefix_and_missing() {
5493        let store = store();
5494        let id = store.put_manifest(&empty_manifest(true)).await.unwrap();
5495
5496        // Exact id resolves to itself.
5497        assert_eq!(
5498            store.resolve_manifest_id(&id).await.unwrap().as_deref(),
5499            Some(id.as_str())
5500        );
5501        // A unique prefix (the DNS-label use case) resolves to the full id.
5502        assert_eq!(
5503            store
5504                .resolve_manifest_id(&id[..16])
5505                .await
5506                .unwrap()
5507                .as_deref(),
5508            Some(id.as_str())
5509        );
5510        // An unknown prefix resolves to nothing.
5511        assert!(store
5512            .resolve_manifest_id("ffffffffffffffff")
5513            .await
5514            .unwrap()
5515            .is_none());
5516    }
5517
5518    #[tokio::test]
5519    async fn delete_site_removes_config_routing_and_aliases() {
5520        let store = store();
5521        let mut cfg = SiteConfig::default();
5522        cfg.domains.primary = Some("blog.example".into());
5523        cfg.domains.wildcards = vec!["*.preview.blog.example".into()];
5524        store
5525            .set_site_config(ProjectRef::DEFAULT, "blog", &cfg)
5526            .await
5527            .unwrap();
5528        // A real deployment so the alias points at something valid.
5529        let id = store.put_manifest(&empty_manifest(true)).await.unwrap();
5530        store
5531            .set_alias(ProjectRef::DEFAULT, "blog", "stable", &id)
5532            .await
5533            .unwrap();
5534
5535        // Present: config stored + its hosts route to it.
5536        assert!(store
5537            .get_site_config(ProjectRef::DEFAULT, "blog")
5538            .await
5539            .unwrap()
5540            .is_some());
5541        assert_eq!(
5542            store
5543                .resolve_site_by_host("blog.example")
5544                .await
5545                .unwrap()
5546                .map(|o| o.site)
5547                .as_deref(),
5548            Some("blog")
5549        );
5550
5551        store
5552            .delete_site(ProjectRef::DEFAULT, "blog")
5553            .await
5554            .unwrap();
5555
5556        // Gone: config pointer, domain routing (host freed), aliases.
5557        assert!(store
5558            .get_site_config(ProjectRef::DEFAULT, "blog")
5559            .await
5560            .unwrap()
5561            .is_none());
5562        assert!(store
5563            .resolve_site_by_host("blog.example")
5564            .await
5565            .unwrap()
5566            .is_none());
5567        assert!(store
5568            .list_aliases(ProjectRef::DEFAULT, "blog")
5569            .await
5570            .unwrap()
5571            .is_empty());
5572
5573        // Idempotent — deleting an absent site is a no-op.
5574        store
5575            .delete_site(ProjectRef::DEFAULT, "blog")
5576            .await
5577            .unwrap();
5578    }
5579
5580    #[tokio::test]
5581    async fn gc_blob_reachability_is_a_cross_project_union() {
5582        use crate::kv::MemoryKv;
5583        // A real blob store so blob deletion is observable.
5584        let store = DeployStore::new(Arc::new(MemStorage::default()), Arc::new(MemoryKv::new()));
5585
5586        // Three blobs: `shared` (referenced by project acme), `keep` (project shop's
5587        // current), and `dead` (referenced by no live deployment anywhere). The blob
5588        // key is the content hash, so hash the actual bytes (put_blob verifies it).
5589        let shared = sha256_hex(b"shared-blob");
5590        let keep = sha256_hex(b"keep-blob");
5591        let dead = sha256_hex(b"dead-blob");
5592        store
5593            .put_blob(&shared, once_bytes(b"shared-blob"))
5594            .await
5595            .unwrap();
5596        store
5597            .put_blob(&keep, once_bytes(b"keep-blob"))
5598            .await
5599            .unwrap();
5600        store
5601            .put_blob(&dead, once_bytes(b"dead-blob"))
5602            .await
5603            .unwrap();
5604
5605        let m_shared = manifest_with(&[("index.html", &shared)]);
5606        let m_keep = manifest_with(&[("index.html", &keep)]);
5607        let m_dead = manifest_with(&[("old.html", &dead)]);
5608        let id_shared = store.put_manifest(&m_shared).await.unwrap();
5609        let id_keep = store.put_manifest(&m_keep).await.unwrap();
5610        let id_dead = store.put_manifest(&m_dead).await.unwrap();
5611
5612        // Project "acme": current → the shared manifest (its only reference to `shared`).
5613        let acme = ProjectRef::new("acme");
5614        store.activate(acme, "site", &id_shared).await.unwrap();
5615        // Project "shop": activated the shared manifest first, then `keep`, so with
5616        // keep_last=1 the shared manifest is an ORPHAN *within shop* — its survival
5617        // depends entirely on acme still referencing it (the cross-project union).
5618        let shop = ProjectRef::new("shop");
5619        store.activate(shop, "site", &id_shared).await.unwrap();
5620        store.activate(shop, "site", &id_keep).await.unwrap();
5621        // `m_dead` is stored but never activated in any project.
5622
5623        let report = store
5624            .collect_garbage_with(
5625                true,
5626                GcOptions {
5627                    keep_last: Some(1),
5628                    ..Default::default()
5629                },
5630            )
5631            .await
5632            .unwrap();
5633
5634        // The truly-dead manifest and its unshared blob are collected.
5635        assert!(store.get_manifest(&id_dead).await.unwrap().is_none());
5636        assert!(
5637            !store.has_blob(&dead).await.unwrap(),
5638            "dead blob is reclaimed"
5639        );
5640        assert_eq!(report.manifests_removed, 1, "only the dead manifest goes");
5641
5642        // The shared manifest + blob SURVIVE: orphaned in shop but current in acme.
5643        // A per-project (non-union) GC would have wrongly collected them.
5644        assert!(
5645            store.get_manifest(&id_shared).await.unwrap().is_some(),
5646            "shared manifest kept by acme's reference"
5647        );
5648        assert!(
5649            store.has_blob(&shared).await.unwrap(),
5650            "shared blob kept — reachability is the union across projects"
5651        );
5652        // shop's current is untouched.
5653        assert!(store.has_blob(&keep).await.unwrap());
5654        assert_eq!(
5655            store.current_id(acme, "site").await.unwrap().as_deref(),
5656            Some(id_shared.as_str())
5657        );
5658    }
5659
5660    #[tokio::test]
5661    async fn same_site_name_in_two_projects_is_isolated() {
5662        use crate::kv::MemoryKv;
5663        let store = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
5664        let acme = ProjectRef::new("acme");
5665        let shop = ProjectRef::new("shop");
5666
5667        // Two projects each have a site literally named "www" — independent records.
5668        let id_a = store.put_manifest(&empty_manifest(false)).await.unwrap();
5669        let id_b = store.put_manifest(&empty_manifest(true)).await.unwrap();
5670        store.activate(acme, "www", &id_a).await.unwrap();
5671        store.activate(shop, "www", &id_b).await.unwrap();
5672
5673        // Distinct current pointers — no cross-talk.
5674        assert_eq!(
5675            store.current_id(acme, "www").await.unwrap().as_deref(),
5676            Some(id_a.as_str())
5677        );
5678        assert_eq!(
5679            store.current_id(shop, "www").await.unwrap().as_deref(),
5680            Some(id_b.as_str())
5681        );
5682        assert_ne!(id_a, id_b);
5683
5684        // Distinct configs + aliases keyed under each project.
5685        let mut cfg_a = SiteConfig::default();
5686        cfg_a.domains.primary = Some("acme.example".into());
5687        store.set_site_config(acme, "www", &cfg_a).await.unwrap();
5688        let mut cfg_b = SiteConfig::default();
5689        cfg_b.domains.primary = Some("shop.example".into());
5690        store.set_site_config(shop, "www", &cfg_b).await.unwrap();
5691        store
5692            .set_alias(acme, "www", "staging", &id_a)
5693            .await
5694            .unwrap();
5695
5696        assert_eq!(
5697            store
5698                .get_site_config(acme, "www")
5699                .await
5700                .unwrap()
5701                .unwrap()
5702                .domains
5703                .primary
5704                .as_deref(),
5705            Some("acme.example")
5706        );
5707        assert_eq!(
5708            store
5709                .get_site_config(shop, "www")
5710                .await
5711                .unwrap()
5712                .unwrap()
5713                .domains
5714                .primary
5715                .as_deref(),
5716            Some("shop.example")
5717        );
5718        // acme's alias is not visible under shop.
5719        assert!(store
5720            .get_alias(acme, "www", "staging")
5721            .await
5722            .unwrap()
5723            .is_some());
5724        assert!(store
5725            .get_alias(shop, "www", "staging")
5726            .await
5727            .unwrap()
5728            .is_none());
5729        assert!(store.list_aliases(shop, "www").await.unwrap().is_empty());
5730
5731        // Each host resolves to its owning (project, site); deleting one leaves the other.
5732        assert_eq!(
5733            store.resolve_site_by_host("acme.example").await.unwrap(),
5734            Some(DomainOwner::new("acme", "www"))
5735        );
5736        assert_eq!(
5737            store.resolve_site_by_host("shop.example").await.unwrap(),
5738            Some(DomainOwner::new("shop", "www"))
5739        );
5740        store.delete_site(acme, "www").await.unwrap();
5741        assert!(store.get_site_config(acme, "www").await.unwrap().is_none());
5742        assert!(
5743            store.get_site_config(shop, "www").await.unwrap().is_some(),
5744            "deleting acme/www must not touch shop/www"
5745        );
5746        assert!(store.current_id(shop, "www").await.unwrap().is_some());
5747    }
5748
5749    #[tokio::test]
5750    async fn project_entity_crud_and_delete_guard() {
5751        use crate::project::Project;
5752        let store = store();
5753        let acme = Project {
5754            version: crate::SCHEMA_VERSION,
5755            name: "acme".into(),
5756            created_at: 1,
5757            meta: Default::default(),
5758            config: Default::default(),
5759            secrets_ref: None,
5760        };
5761        // Create + read back; content-addressed id round-trips.
5762        let hash = store.put_project(&acme).await.unwrap();
5763        assert_eq!(hash, acme.id());
5764        assert_eq!(store.get_project("acme").await.unwrap(), Some(acme.clone()));
5765        assert!(store.get_project("ghost").await.unwrap().is_none());
5766        // `default` is always present (the always-exists backstop), alongside acme.
5767        let names: Vec<String> = store
5768            .list_projects()
5769            .await
5770            .unwrap()
5771            .into_iter()
5772            .map(|p| p.name)
5773            .collect();
5774        assert_eq!(names, vec!["acme".to_string(), "default".to_string()]);
5775
5776        // Delete refuses while the project owns a resource…
5777        store
5778            .set_site_config(ProjectRef::new("acme"), "www", &SiteConfig::default())
5779            .await
5780            .unwrap();
5781        assert!(matches!(
5782            store.delete_project("acme").await,
5783            Err(DeployError::Conflict(_))
5784        ));
5785        // …and succeeds once it is empty.
5786        store
5787            .delete_site(ProjectRef::new("acme"), "www")
5788            .await
5789            .unwrap();
5790        assert!(store.delete_project("acme").await.unwrap());
5791        assert!(store.get_project("acme").await.unwrap().is_none());
5792
5793        // The reserved default project can never be deleted.
5794        assert!(matches!(
5795            store.delete_project("default").await,
5796            Err(DeployError::Conflict(_))
5797        ));
5798    }
5799
5800    #[tokio::test]
5801    async fn open_blob_cached_caches_small_and_streams_large() {
5802        use crate::kv::MemoryKv;
5803        let store = DeployStore::new(Arc::new(MemStorage::default()), Arc::new(MemoryKv::new()));
5804
5805        // A small blob is served from the in-memory cache, byte-for-byte, and lands
5806        // resident so the next read is a hit (no re-open of storage).
5807        let small = b"hello, static hot path";
5808        let small_hash = sha256_hex(small);
5809        store
5810            .put_blob(&small_hash, once_bytes(small))
5811            .await
5812            .unwrap();
5813        match store
5814            .open_blob_cached(&small_hash, small.len() as u64)
5815            .await
5816            .unwrap()
5817        {
5818            BlobBody::Cached(bytes) => assert_eq!(&bytes[..], small),
5819            BlobBody::Stream(_) => panic!("small blob should be cached, not streamed"),
5820            BlobBody::Mapped(_) => panic!("small blob should be cached, not mapped"),
5821        }
5822        {
5823            let cache = store.blob_body_cache.read().unwrap();
5824            assert!(cache.map.contains_key(&small_hash));
5825            assert_eq!(cache.bytes, small.len());
5826        }
5827        assert!(matches!(
5828            store
5829                .open_blob_cached(&small_hash, small.len() as u64)
5830                .await
5831                .unwrap(),
5832            BlobBody::Cached(_)
5833        ));
5834
5835        // A blob over the threshold always streams and is never cached.
5836        let large = vec![7u8; SMALL_BLOB_CACHE_MAX as usize + 1];
5837        let large_hash = sha256_hex(&large);
5838        let put_body = {
5839            let large = large.clone();
5840            futures::stream::once(async move { Ok(bytes::Bytes::from(large)) }).boxed()
5841        };
5842        store.put_blob(&large_hash, put_body).await.unwrap();
5843        match store
5844            .open_blob_cached(&large_hash, large.len() as u64)
5845            .await
5846            .unwrap()
5847        {
5848            BlobBody::Stream(object) => {
5849                let mut body = object.body;
5850                let mut got = Vec::new();
5851                while let Some(chunk) = body.next().await {
5852                    got.extend_from_slice(&chunk.unwrap());
5853                }
5854                assert_eq!(got, large);
5855            }
5856            BlobBody::Cached(_) => panic!("large blob must stream, not cache"),
5857            // MemStorage has no local file to map, so a large blob streams here.
5858            BlobBody::Mapped(_) => panic!("MemStorage can't memory-map; expected a stream"),
5859        }
5860        assert!(!store
5861            .blob_body_cache
5862            .read()
5863            .unwrap()
5864            .map
5865            .contains_key(&large_hash));
5866    }
5867
5868    #[tokio::test]
5869    async fn enumerate_and_purge_project_are_scoped() {
5870        use crate::compute::{
5871            ComputeSpec, ComputeWorkload, PlacementConstraints, RestartPolicy, RootSource,
5872            VolumeRef,
5873        };
5874        use crate::function::{Function, FunctionConfig, Lifecycle, Owner};
5875        use crate::kv::MemoryKv;
5876        use crate::project::Project;
5877
5878        let kv = Arc::new(MemoryKv::new());
5879        let store = DeployStore::new(Arc::new(NullStorage), kv.clone());
5880        let acme = ProjectRef::new("acme");
5881        let other = ProjectRef::new("other");
5882
5883        // Register the project.
5884        store
5885            .put_project(&Project {
5886                version: crate::SCHEMA_VERSION,
5887                name: "acme".into(),
5888                created_at: 1,
5889                meta: Default::default(),
5890                config: Default::default(),
5891                secrets_ref: None,
5892            })
5893            .await
5894            .unwrap();
5895
5896        // A site with an exact-host domain claim (freed by delete_site).
5897        let mut cfg = SiteConfig::default();
5898        cfg.domains.primary = Some("acme.example".into());
5899        store.set_site_config(acme, "www", &cfg).await.unwrap();
5900        // Attach the host so the global `domain/*` routing claim exists.
5901        kv.put(
5902            &keys::domain("acme.example"),
5903            crate::project::DomainOwner::new("acme", "www").to_bytes(),
5904        )
5905        .await
5906        .unwrap();
5907
5908        // A function.
5909        store
5910            .put_function(
5911                acme,
5912                &Function::new(
5913                    "worker",
5914                    Owner::Project("acme".into()),
5915                    "component-hash",
5916                    FunctionConfig::default(),
5917                    Lifecycle::default(),
5918                    0,
5919                ),
5920            )
5921            .await
5922            .unwrap();
5923
5924        // A compute workload whose active spec mounts a named volume.
5925        let spec = ComputeSpec {
5926            version: crate::SCHEMA_VERSION,
5927            root: RootSource::Rootfs("r".repeat(64)),
5928            kernel: "k".repeat(64),
5929            kernel_cmdline: None,
5930            vcpus: 1,
5931            mem_mib: 256,
5932            entrypoint: vec!["/app".into()],
5933            env: Default::default(),
5934            port: 8080,
5935            restart: RestartPolicy::Always,
5936            startup_grace_secs: 30,
5937            scale_to_zero: false,
5938            volumes: vec![VolumeRef {
5939                mount: "/data".into(),
5940                name: "pg-data".into(),
5941                size_mib: 1024,
5942            }],
5943            writable_root: false,
5944            cap_add: Vec::new(),
5945            user: None,
5946            isolation: Default::default(),
5947            prefer_backend: None,
5948            bindings: vec![],
5949        };
5950        let hash = store.put_compute_spec(&spec).await.unwrap();
5951        store
5952            .set_compute_workload(
5953                acme,
5954                &ComputeWorkload {
5955                    version: crate::SCHEMA_VERSION,
5956                    name: "pg".into(),
5957                    active: hash,
5958                    replicas: 1,
5959                    placement: PlacementConstraints::default(),
5960                },
5961            )
5962            .await
5963            .unwrap();
5964
5965        // A secret (same control-plane KV, under project/acme/secret/).
5966        kv.put(&keys::secret(acme, "api-key"), b"sealed".to_vec())
5967            .await
5968            .unwrap();
5969        // A GraphQL safelist entry + a subgraph (both OUTSIDE project/acme/).
5970        kv.put(
5971            &format!("{}deadbeef", keys::graphql_safelist_prefix(acme)),
5972            b"query{me}".to_vec(),
5973        )
5974        .await
5975        .unwrap();
5976        kv.put(
5977            &format!("{}users", keys::graphql_subgraph_prefix(acme)),
5978            b"type Query{me:ID}".to_vec(),
5979        )
5980        .await
5981        .unwrap();
5982        kv.put("graphql/acme/version", 3u64.to_be_bytes().to_vec())
5983            .await
5984            .unwrap();
5985        // A reverse-index entry for acme, plus one for `other` that must survive.
5986        kv.put(
5987            &crate::project::owner_key(crate::project::owner_kind::COMPUTE, "pg"),
5988            b"acme".to_vec(),
5989        )
5990        .await
5991        .unwrap();
5992        kv.put(
5993            &crate::project::owner_key(crate::project::owner_kind::SITE, "elsewhere"),
5994            b"other".to_vec(),
5995        )
5996        .await
5997        .unwrap();
5998
5999        // A second project's resource + a shared global CAS key — must be untouched.
6000        store
6001            .set_site_config(other, "shop", &SiteConfig::default())
6002            .await
6003            .unwrap();
6004        kv.put("siteconfig/shared-cas", b"body".to_vec())
6005            .await
6006            .unwrap();
6007
6008        // --- enumerate reports each family with the right names + the volume ---
6009        let plan = store.enumerate_project_resources("acme").await.unwrap();
6010        assert_eq!(plan.project, "acme");
6011        assert_eq!(plan.sites, vec!["www".to_string()]);
6012        assert_eq!(plan.functions, vec!["worker".to_string()]);
6013        assert_eq!(plan.compute.len(), 1);
6014        assert_eq!(plan.compute[0].name, "pg");
6015        assert_eq!(plan.compute[0].volumes, vec!["pg-data".to_string()]);
6016        assert_eq!(plan.all_volumes(), vec!["pg-data".to_string()]);
6017        assert_eq!(plan.secrets, vec!["api-key".to_string()]);
6018        assert_eq!(plan.safelist, 1);
6019        assert_eq!(plan.subgraphs, vec!["users".to_string()]);
6020        assert!(!plan.is_empty());
6021
6022        // --- enumerate mutated nothing (the config-pointer site is still present) ---
6023        assert!(store.get_project("acme").await.unwrap().is_some());
6024        assert!(store.get_site_config(acme, "www").await.unwrap().is_some());
6025
6026        // --- teardown mirrors the cascade: delete the resources, then purge ---
6027        store.delete_site(acme, "www").await.unwrap();
6028        store.delete_function(acme, "worker").await.unwrap();
6029        store.delete_compute_workload(acme, "pg").await.unwrap();
6030        let purged = store.purge_project("acme").await.unwrap();
6031        assert!(purged > 0, "purge removed residual keys");
6032
6033        // acme is gone: pointer, resource prefix, graphql, safelist, reverse index.
6034        assert!(store.get_project("acme").await.unwrap().is_none());
6035        assert!(kv
6036            .list_prefix(&crate::project::resource_prefix("acme"))
6037            .await
6038            .unwrap()
6039            .is_empty());
6040        assert!(kv.list_prefix("graphql/acme/").await.unwrap().is_empty());
6041        assert!(kv
6042            .list_prefix(&keys::graphql_safelist_prefix(acme))
6043            .await
6044            .unwrap()
6045            .is_empty());
6046        assert!(kv
6047            .get(&crate::project::pointer_key("acme"))
6048            .await
6049            .unwrap()
6050            .is_none());
6051        assert!(kv
6052            .get(&crate::project::history_key("acme"))
6053            .await
6054            .unwrap()
6055            .is_none());
6056        assert!(kv
6057            .get(&crate::project::owner_key(
6058                crate::project::owner_kind::COMPUTE,
6059                "pg"
6060            ))
6061            .await
6062            .unwrap()
6063            .is_none());
6064        // The freed host claim is gone.
6065        assert!(kv
6066            .get(&keys::domain("acme.example"))
6067            .await
6068            .unwrap()
6069            .is_none());
6070
6071        // The second project + the shared global CAS key are intact.
6072        assert!(store
6073            .get_site_config(other, "shop")
6074            .await
6075            .unwrap()
6076            .is_some());
6077        assert!(!kv
6078            .list_prefix(&crate::project::resource_prefix("other"))
6079            .await
6080            .unwrap()
6081            .is_empty());
6082        assert_eq!(
6083            kv.get(&crate::project::owner_key(
6084                crate::project::owner_kind::SITE,
6085                "elsewhere"
6086            ))
6087            .await
6088            .unwrap(),
6089            Some(b"other".to_vec())
6090        );
6091        assert!(kv.get("siteconfig/shared-cas").await.unwrap().is_some());
6092
6093        // purge refuses the reserved default.
6094        assert!(matches!(
6095            store.purge_project("default").await,
6096            Err(DeployError::Conflict(_))
6097        ));
6098    }
6099
6100    /// A legacy (pre-v0.3.12) replica record was persisted with NO `project` field
6101    /// (it did not exist yet), so it deserializes with an empty `handle.project`.
6102    /// On read, `list_replica_states` / `list_all_replica_states` must **backfill**
6103    /// the project from the KV key (`project/<proj>/compute_state/…`) so the backend
6104    /// derives the REAL project's identity/IPAM key — never `""`. This is the
6105    /// adoption/reconcile backfill point.
6106    #[tokio::test]
6107    async fn read_backfills_project_into_a_legacy_replica_record() {
6108        use crate::compute::{replica_state_key, Endpoint, ReplicaPhase, Scheme, Snapshot};
6109        use crate::kv::MemoryKv;
6110        let kv = Arc::new(MemoryKv::new());
6111        let store = DeployStore::new(Arc::new(NullStorage), kv.clone());
6112
6113        // A legacy record body: exactly what an old binary wrote — no `project` key
6114        // on the handle, and a parked snapshot with no `project` key either. Written
6115        // raw under the acme-scoped replica-state key.
6116        let legacy = serde_json::json!({
6117            "handle": { "workload": "web", "replica": 0, "backend_ref": "10.0.0.5:8080" },
6118            "node": 1,
6119            "backend": "container",
6120            "endpoint": { "scheme": "http", "host": "10.0.0.5", "port": 8080 },
6121            "healthy": false,
6122            "phase": "Zero",
6123            "snapshot": { "workload": "web", "replica": 0, "data_ref": "img|/rootfs|10.0.0.5|8080" }
6124        });
6125        // Sanity: it really has no project field (the pre-v0.3.12 shape).
6126        assert!(legacy["handle"].get("project").is_none());
6127        kv.put(
6128            &replica_state_key("acme", "web", 0),
6129            serde_json::to_vec(&legacy).unwrap(),
6130        )
6131        .await
6132        .unwrap();
6133
6134        // Per-workload read backfills the project onto the handle AND the snapshot.
6135        let states = store
6136            .list_replica_states(ProjectRef::new("acme"), "web")
6137            .await
6138            .unwrap();
6139        assert_eq!(states.len(), 1);
6140        assert_eq!(
6141            states[0].handle.project, "acme",
6142            "handle project backfilled"
6143        );
6144        assert_eq!(states[0].handle.workload, "web");
6145        assert_eq!(
6146            states[0].snapshot.as_ref().unwrap().project,
6147            "acme",
6148            "parked snapshot's project backfilled too"
6149        );
6150        assert_eq!(states[0].phase, ReplicaPhase::Zero);
6151
6152        // The cross-project read also backfills from each record's own key.
6153        let all = store.list_all_replica_states().await.unwrap();
6154        assert_eq!(all.len(), 1);
6155        assert_eq!(all[0].handle.project, "acme");
6156
6157        // And a freshly-written record (project already set) round-trips unchanged —
6158        // the backfill is idempotent, never overwriting a real project.
6159        store
6160            .set_replica_state(
6161                ProjectRef::new("beta"),
6162                &crate::compute::ObservedInstance {
6163                    handle: crate::compute::InstanceHandle {
6164                        project: "beta".into(),
6165                        workload: "web".into(),
6166                        replica: 0,
6167                        backend_ref: "10.0.0.6:8080".into(),
6168                    },
6169                    node: 1,
6170                    backend: "container".into(),
6171                    endpoint: Endpoint {
6172                        scheme: Scheme::Http,
6173                        host: "10.0.0.6".into(),
6174                        port: 8080,
6175                    },
6176                    region: None,
6177                    healthy: true,
6178                    started_at: None,
6179                    phase: ReplicaPhase::Running,
6180                    snapshot: None::<Snapshot>,
6181                },
6182            )
6183            .await
6184            .unwrap();
6185        let beta = store
6186            .list_replica_states(ProjectRef::new("beta"), "web")
6187            .await
6188            .unwrap();
6189        assert_eq!(beta[0].handle.project, "beta");
6190    }
6191}