Skip to main content

boatramp_types/
project.rs

1//! Projects (= Uchron **Workspaces**): the owning boundary for a set of sites,
2//! functions, and compute workloads, plus shared config/secrets. A Project is
3//! content-addressed and atomically activated exactly like a site deployment — an
4//! immutable `projectver/<hash>` spec body, a mutable `projectmeta/<name>` pointer, and
5//! a bounded history ring for rollback — so the CLI, control plane, and store agree on
6//! one wire shape.
7//!
8//! Every site/function/compute resource lives under the `project/<name>/…` key prefix
9//! (see [`resource_prefix`]) — that prefix *is* the authoritative membership statement,
10//! and it is what every guard consults (e.g. `delete_project` refuses a non-empty
11//! project by scanning the prefix).
12//!
13//! A global reverse index `owner/<kind>/<name>` → project (see [`owner_key`]) is
14//! **built once by the migration** as a derived lookup hint. It is **not** currently
15//! maintained on create/delete, and no code consults it for an authorization or
16//! uniqueness decision — so it can drift and must **not** be treated as an enforced
17//! single-membership guard. Make it a maintained derived index (written/deleted in the
18//! same batch as each resource) before relying on it.
19
20use std::collections::BTreeMap;
21
22use serde::{Deserialize, Serialize};
23
24use crate::manifest::sha256_hex;
25
26/// The reserved project every pre-project resource migrates into, and the default a
27/// CLI user who never names a project targets. This is the **only** place the literal
28/// is written (everything else references this constant).
29pub const DEFAULT_PROJECT: &str = "default";
30
31/// KV prefix for the mutable pointer `projectmeta/<name>` → active spec hash.
32pub const POINTER_PREFIX: &str = "projectmeta/";
33/// KV prefix for the immutable, content-addressed project spec body.
34pub const SPEC_PREFIX: &str = "projectver/";
35/// KV prefix for the global reverse ownership index `owner/<kind>/<name>` → project.
36pub const OWNER_PREFIX: &str = "owner/";
37
38/// The mutable pointer key for a project (→ its active spec hash).
39pub fn pointer_key(project: &str) -> String {
40    format!("{POINTER_PREFIX}{project}")
41}
42
43/// The immutable spec-body key for a content hash.
44pub fn spec_key(hash: &str) -> String {
45    format!("{SPEC_PREFIX}{hash}")
46}
47
48/// The rollback-history key for a project.
49pub fn history_key(project: &str) -> String {
50    format!("project-history/{project}")
51}
52
53/// The prefix under which **all** of a project's owned resources live (sites,
54/// functions, compute, …). A single-project sweep is `list_prefix(resource_prefix(p))`.
55pub fn resource_prefix(project: &str) -> String {
56    format!("project/{project}/")
57}
58
59/// The reverse-index key naming which project owns `<kind>/<name>` (see [`owner_kind`]).
60pub fn owner_key(kind: &str, name: &str) -> String {
61    format!("{OWNER_PREFIX}{kind}/{name}")
62}
63
64/// The resource kinds recorded in the reverse ownership index.
65pub mod owner_kind {
66    /// A site.
67    pub const SITE: &str = "site";
68    /// A function.
69    pub const FUNCTION: &str = "function";
70    /// A compute workload.
71    pub const COMPUTE: &str = "compute";
72}
73
74/// Human-facing project metadata.
75#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(default, deny_unknown_fields)]
77pub struct ProjectMeta {
78    /// Display name (defaults to the slug).
79    #[serde(skip_serializing_if = "String::is_empty")]
80    pub display: String,
81    /// Free-text description.
82    #[serde(skip_serializing_if = "String::is_empty")]
83    pub description: String,
84    /// Arbitrary labels.
85    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
86    pub labels: BTreeMap<String, String>,
87}
88
89/// Project-level shared defaults its sites/functions/compute inherit unless overridden.
90/// Kept small; grows as needs surface.
91#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(default, deny_unknown_fields)]
93pub struct ProjectConfig {
94    /// Default region for the project's compute/replicas (FA-8); `None` = agnostic.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub region: Option<String>,
97}
98
99/// An immutable, content-addressed project version — the analogue of a site deployment
100/// manifest. Stored at `projectver/<hash>`; the mutable `projectmeta/<name>` points at
101/// the active one (atomic activation + rollback, same model as a site).
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(deny_unknown_fields)]
104pub struct Project {
105    /// Pinned schema discriminant (`v1`).
106    #[serde(default = "crate::schema_version")]
107    pub version: u32,
108    /// The project's slug — its stable identity (unique, immutable, no `/`).
109    pub name: String,
110    /// Creation time (unix secs).
111    pub created_at: u64,
112    /// Human metadata.
113    #[serde(default)]
114    pub meta: ProjectMeta,
115    /// Shared defaults.
116    #[serde(default)]
117    pub config: ProjectConfig,
118    /// Hash of the project's sealed shared-secrets body (content-addressed like
119    /// `siteconfig`); `None` ⇒ no shared secrets.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub secrets_ref: Option<String>,
122}
123
124impl Project {
125    /// The content hash of this project version — its `projectver/<hash>` id. Computed
126    /// over canonical JSON so identical projects dedupe (like a deployment id).
127    pub fn id(&self) -> String {
128        let canonical = serde_json::to_vec(self).expect("Project serializes");
129        sha256_hex(&canonical)
130    }
131}
132
133/// The owner recorded in a **global** domain-routing index value (`domain/<host>`,
134/// `wildcard/<suffix>`, `httpchallenge/<host>/<token>`). Serializes as `{project,
135/// site}`; a **bare string** deserializes as `(DEFAULT_PROJECT, <string>)` so a
136/// not-yet-migrated (layout-1) index still reads correctly while the migration runs.
137///
138/// Note the two host-normalization schemes that key into this shared value type:
139/// `domain/`/`wildcard/` keys canonicalize the host via `Host::routing_key`, while
140/// `httpchallenge/`/`domainverify/` keys use `Host::verification`. They must not be
141/// crossed — a lookup keyed under one normalization must be read under the same one.
142#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
143pub struct DomainOwner {
144    /// The owning project.
145    pub project: String,
146    /// The site within the project.
147    pub site: String,
148    /// An opaque per-domain **tenant context tag** — the value the host binds as the in-site
149    /// tenant when a function/site resolves "own" via [`crate::tenancy::TenantSource::Domain`]
150    /// (storefronts: 1 domain : 1 tenant). Set at domain attach via the domain admin API;
151    /// wildcards/aliases inherit the base's tag. `None` ⇒ this domain carries no tenant context
152    /// (a domain source then fails closed). Omitted from the serialized form when absent, so a
153    /// pre-existing `{project, site}` value still reads (and round-trips) unchanged.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub context: Option<String>,
156}
157
158impl DomainOwner {
159    /// A `(project, site)` owner with no tenant context tag.
160    pub fn new(project: impl Into<String>, site: impl Into<String>) -> Self {
161        Self {
162            project: project.into(),
163            site: site.into(),
164            context: None,
165        }
166    }
167
168    /// Attach an opaque tenant context tag (the [`TenantSource::Domain`](crate::tenancy::TenantSource::Domain)
169    /// value). An empty tag is treated as none.
170    pub fn with_context(mut self, context: impl Into<String>) -> Self {
171        let c = context.into();
172        self.context = (!c.is_empty()).then_some(c);
173        self
174    }
175
176    /// The canonical stored form of a domain-index value: the `{project, site}`
177    /// JSON object.
178    pub fn to_bytes(&self) -> Vec<u8> {
179        serde_json::to_vec(self).expect("DomainOwner serializes")
180    }
181
182    /// Read a domain-index value, tolerant of **three** on-disk forms so a reader
183    /// never breaks mid-migration:
184    /// 1. the current `{project, site}` JSON object;
185    /// 2. a JSON bare string `"blog"` → `(default, "blog")`;
186    /// 3. a **raw, unquoted** site name `blog` (the pre-0.2.0 layout, written as
187    ///    `site.as_bytes()` — not valid JSON) → `(default, "blog")`.
188    pub fn from_bytes(bytes: &[u8]) -> Self {
189        if let Ok(owner) = serde_json::from_slice::<Self>(bytes) {
190            return owner;
191        }
192        // Legacy layout-1 value: the bare site name stored as raw bytes.
193        Self::new(DEFAULT_PROJECT, String::from_utf8_lossy(bytes).into_owned())
194    }
195}
196
197impl<'de> Deserialize<'de> for DomainOwner {
198    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
199    where
200        D: serde::Deserializer<'de>,
201    {
202        #[derive(Deserialize)]
203        #[serde(untagged)]
204        enum Raw {
205            /// Layout 1: a bare site name.
206            Bare(String),
207            /// Layout 2: `{project, site}` (+ optional `context`, appended in v0.4.0).
208            Full {
209                project: String,
210                site: String,
211                #[serde(default)]
212                context: Option<String>,
213            },
214        }
215        Ok(match Raw::deserialize(deserializer)? {
216            Raw::Bare(site) => Self {
217                project: DEFAULT_PROJECT.to_string(),
218                site,
219                context: None,
220            },
221            Raw::Full {
222                project,
223                site,
224                context,
225            } => Self {
226                project,
227                site,
228                context,
229            },
230        })
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    fn sample() -> Project {
239        Project {
240            version: crate::SCHEMA_VERSION,
241            name: "acme".into(),
242            created_at: 1_700_000_000,
243            meta: ProjectMeta {
244                display: "Acme Corp".into(),
245                ..Default::default()
246            },
247            config: ProjectConfig {
248                region: Some("eu-west".into()),
249            },
250            secrets_ref: None,
251        }
252    }
253
254    #[test]
255    fn project_id_is_stable_and_content_addressed() {
256        let a = sample();
257        let mut b = sample();
258        assert_eq!(a.id(), b.id(), "identical projects share an id");
259        b.name = "other".into();
260        assert_ne!(a.id(), b.id(), "a changed field changes the id");
261        assert_eq!(a.id().len(), 64);
262    }
263
264    #[test]
265    fn key_builders() {
266        assert_eq!(pointer_key("acme"), "projectmeta/acme");
267        assert_eq!(spec_key("deadbeef"), "projectver/deadbeef");
268        assert_eq!(history_key("acme"), "project-history/acme");
269        assert_eq!(resource_prefix("acme"), "project/acme/");
270        assert_eq!(owner_key(owner_kind::SITE, "blog"), "owner/site/blog");
271    }
272
273    #[test]
274    fn domain_owner_reads_bare_and_full() {
275        // Layout 1: a bare string → the default project.
276        let bare: DomainOwner = serde_json::from_str("\"blog\"").unwrap();
277        assert_eq!(bare, DomainOwner::new(DEFAULT_PROJECT, "blog"));
278        // Layout 2: a {project, site} object, verbatim.
279        let full: DomainOwner =
280            serde_json::from_str(r#"{"project":"acme","site":"shop"}"#).unwrap();
281        assert_eq!(full, DomainOwner::new("acme", "shop"));
282        // Serializes as the object form + round-trips.
283        let round: DomainOwner =
284            serde_json::from_slice(&serde_json::to_vec(&full).unwrap()).unwrap();
285        assert_eq!(round, full);
286    }
287
288    #[test]
289    fn domain_owner_from_bytes_tolerates_all_layouts() {
290        // Current object form round-trips.
291        let owner = DomainOwner::new("acme", "shop");
292        assert_eq!(DomainOwner::from_bytes(&owner.to_bytes()), owner);
293        // JSON bare string → default project.
294        assert_eq!(
295            DomainOwner::from_bytes(b"\"blog\""),
296            DomainOwner::new(DEFAULT_PROJECT, "blog")
297        );
298        // Pre-0.2.0 raw (unquoted) site name, not valid JSON → default project.
299        assert_eq!(
300            DomainOwner::from_bytes(b"blog"),
301            DomainOwner::new(DEFAULT_PROJECT, "blog")
302        );
303        // A raw site name that looks like a JSON scalar still reads as a site name.
304        assert_eq!(
305            DomainOwner::from_bytes(b"123"),
306            DomainOwner::new(DEFAULT_PROJECT, "123")
307        );
308    }
309
310    #[test]
311    fn domain_owner_context_tag_round_trips_and_stays_back_compatible() {
312        // A pre-v0.4.0 value with no context reads with context None and re-serializes WITHOUT a
313        // context key (so it stays byte-compatible with old readers).
314        let legacy: DomainOwner =
315            serde_json::from_str(r#"{"project":"acme","site":"shop"}"#).unwrap();
316        assert_eq!(legacy.context, None);
317        assert_eq!(
318            String::from_utf8(legacy.to_bytes()).unwrap(),
319            r#"{"project":"acme","site":"shop"}"#
320        );
321        // A tagged value round-trips the tenant context.
322        let tagged = DomainOwner::new("acme", "shop").with_context("acme-store");
323        assert_eq!(tagged.context.as_deref(), Some("acme-store"));
324        assert_eq!(DomainOwner::from_bytes(&tagged.to_bytes()), tagged);
325        // An empty tag is treated as none (never binds an empty-string tenant).
326        assert_eq!(
327            DomainOwner::new("acme", "shop").with_context("").context,
328            None
329        );
330    }
331}