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}