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}
149
150impl DomainOwner {
151    /// A `(project, site)` owner.
152    pub fn new(project: impl Into<String>, site: impl Into<String>) -> Self {
153        Self {
154            project: project.into(),
155            site: site.into(),
156        }
157    }
158
159    /// The canonical stored form of a domain-index value: the `{project, site}`
160    /// JSON object.
161    pub fn to_bytes(&self) -> Vec<u8> {
162        serde_json::to_vec(self).expect("DomainOwner serializes")
163    }
164
165    /// Read a domain-index value, tolerant of **three** on-disk forms so a reader
166    /// never breaks mid-migration:
167    /// 1. the current `{project, site}` JSON object;
168    /// 2. a JSON bare string `"blog"` → `(default, "blog")`;
169    /// 3. a **raw, unquoted** site name `blog` (the pre-0.2.0 layout, written as
170    ///    `site.as_bytes()` — not valid JSON) → `(default, "blog")`.
171    pub fn from_bytes(bytes: &[u8]) -> Self {
172        if let Ok(owner) = serde_json::from_slice::<Self>(bytes) {
173            return owner;
174        }
175        // Legacy layout-1 value: the bare site name stored as raw bytes.
176        Self::new(DEFAULT_PROJECT, String::from_utf8_lossy(bytes).into_owned())
177    }
178}
179
180impl<'de> Deserialize<'de> for DomainOwner {
181    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
182    where
183        D: serde::Deserializer<'de>,
184    {
185        #[derive(Deserialize)]
186        #[serde(untagged)]
187        enum Raw {
188            /// Layout 1: a bare site name.
189            Bare(String),
190            /// Layout 2: `{project, site}`.
191            Full { project: String, site: String },
192        }
193        Ok(match Raw::deserialize(deserializer)? {
194            Raw::Bare(site) => Self {
195                project: DEFAULT_PROJECT.to_string(),
196                site,
197            },
198            Raw::Full { project, site } => Self { project, site },
199        })
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    fn sample() -> Project {
208        Project {
209            version: crate::SCHEMA_VERSION,
210            name: "acme".into(),
211            created_at: 1_700_000_000,
212            meta: ProjectMeta {
213                display: "Acme Corp".into(),
214                ..Default::default()
215            },
216            config: ProjectConfig {
217                region: Some("eu-west".into()),
218            },
219            secrets_ref: None,
220        }
221    }
222
223    #[test]
224    fn project_id_is_stable_and_content_addressed() {
225        let a = sample();
226        let mut b = sample();
227        assert_eq!(a.id(), b.id(), "identical projects share an id");
228        b.name = "other".into();
229        assert_ne!(a.id(), b.id(), "a changed field changes the id");
230        assert_eq!(a.id().len(), 64);
231    }
232
233    #[test]
234    fn key_builders() {
235        assert_eq!(pointer_key("acme"), "projectmeta/acme");
236        assert_eq!(spec_key("deadbeef"), "projectver/deadbeef");
237        assert_eq!(history_key("acme"), "project-history/acme");
238        assert_eq!(resource_prefix("acme"), "project/acme/");
239        assert_eq!(owner_key(owner_kind::SITE, "blog"), "owner/site/blog");
240    }
241
242    #[test]
243    fn domain_owner_reads_bare_and_full() {
244        // Layout 1: a bare string → the default project.
245        let bare: DomainOwner = serde_json::from_str("\"blog\"").unwrap();
246        assert_eq!(bare, DomainOwner::new(DEFAULT_PROJECT, "blog"));
247        // Layout 2: a {project, site} object, verbatim.
248        let full: DomainOwner =
249            serde_json::from_str(r#"{"project":"acme","site":"shop"}"#).unwrap();
250        assert_eq!(full, DomainOwner::new("acme", "shop"));
251        // Serializes as the object form + round-trips.
252        let round: DomainOwner =
253            serde_json::from_slice(&serde_json::to_vec(&full).unwrap()).unwrap();
254        assert_eq!(round, full);
255    }
256
257    #[test]
258    fn domain_owner_from_bytes_tolerates_all_layouts() {
259        // Current object form round-trips.
260        let owner = DomainOwner::new("acme", "shop");
261        assert_eq!(DomainOwner::from_bytes(&owner.to_bytes()), owner);
262        // JSON bare string → default project.
263        assert_eq!(
264            DomainOwner::from_bytes(b"\"blog\""),
265            DomainOwner::new(DEFAULT_PROJECT, "blog")
266        );
267        // Pre-0.2.0 raw (unquoted) site name, not valid JSON → default project.
268        assert_eq!(
269            DomainOwner::from_bytes(b"blog"),
270            DomainOwner::new(DEFAULT_PROJECT, "blog")
271        );
272        // A raw site name that looks like a JSON scalar still reads as a site name.
273        assert_eq!(
274            DomainOwner::from_bytes(b"123"),
275            DomainOwner::new(DEFAULT_PROJECT, "123")
276        );
277    }
278}