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}