Skip to main content

gatehouse_runtime_protocol/lifecycle/journal/storage/
slot.rs

1//! A protected service resource fence, distinct from legacy lease v2.
2use super::*;
3
4#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5#[serde(deny_unknown_fields)]
6struct Record {
7    schema: String,
8    fence: u64,
9    released: bool,
10    resolve: Request,
11    plan: CreatePlan,
12    healthcheck: Vec<String>,
13}
14impl Record {
15    fn validate(&self) -> Result<()> {
16        if self.schema != "gatehouse-lifecycle-service-slot/v1" || self.fence == 0 {
17            return Err(Invalid("invalid service slot schema/fence"));
18        }
19        self.plan.validate(&self.resolve)?;
20        if !self.healthcheck.is_empty() {
21            let mut health = self.resolve.clone();
22            health.operation = Operation::HealthExec {
23                target: Target {
24                    object_id: "0".repeat(64),
25                    name: self.plan.name.clone(),
26                    labels: self.plan.labels.clone(),
27                    container_port: self.plan.container_port,
28                },
29                argv: self.healthcheck.clone(),
30                timeout_ms: 30000,
31            };
32            health.binding.operation_key = health.operation_key()?;
33            health.validate()?;
34        }
35        Ok(())
36    }
37    fn bytes(&self) -> Result<Vec<u8>> {
38        self.validate()?;
39        canonical::encode(&serde_json::to_value(self).map_err(|_| Invalid("slot encoding failed"))?)
40    }
41    fn journal_name(&self) -> String {
42        format!("journal-{}", self.resolve.binding.lease_invocation)
43    }
44}
45
46/// Holds the resource lock and immutable start specification. Drop never releases
47/// the durable fence. Only the bound journal's verified absence can release it.
48pub struct ServiceSlot {
49    root: PathBuf,
50    directory: LockedDirectory,
51    record: Record,
52    bytes: Vec<u8>,
53    reserved: bool,
54}
55impl ServiceSlot {
56    pub fn acquire(
57        root: &Path,
58        resolve: Request,
59        plan: CreatePlan,
60        healthcheck: Vec<String>,
61    ) -> Result<Self> {
62        Self::acquire_with(root, resolve, plan, healthcheck, |_| Ok(()))
63    }
64    fn acquire_with(
65        root: &Path,
66        resolve: Request,
67        plan: CreatePlan,
68        healthcheck: Vec<String>,
69        mut stage: impl FnMut(u8) -> Result<()>,
70    ) -> Result<Self> {
71        let directory = Journal::directory(root)?;
72        let previous = Journal::read(&directory, "service.json")?;
73        if previous.is_none() {
74            for member in ["active", "lease.json", "reservation.json", "tombstone.json"] {
75                if Journal::read(&directory, member)?.is_some() {
76                    return Err(Invalid(
77                        "slot cannot adopt an existing standalone or legacy lease",
78                    ));
79                }
80            }
81        }
82        let mut record = Record {
83            schema: "gatehouse-lifecycle-service-slot/v1".into(),
84            fence: 1,
85            released: false,
86            resolve,
87            plan,
88            healthcheck,
89        };
90        record.validate()?;
91        if let Some(bytes) = previous {
92            record.fence = Self::released_fence(root, &directory, &record, &bytes)?;
93        }
94        let name = CString::new(record.journal_name()).unwrap();
95        if Journal::at(
96            &directory,
97            name.to_str().unwrap(),
98            libc::O_RDONLY | libc::O_DIRECTORY,
99        )?
100        .is_some()
101        {
102            return Err(Invalid("service invocation reused"));
103        }
104        let bytes = record.bytes()?;
105        // Persist the fence before creating any journal or yielding a token.
106        Journal::atomic_with(&directory, "service.json", &bytes, &mut stage)?;
107        stage(3)?;
108        // SAFETY: fixed prefix and validated hexadecimal invocation; held directory.
109        if unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) } != 0 {
110            return Err(Invalid("cannot provision slot journal; fence retained"));
111        }
112        io(directory.sync_all())?;
113        let slot = Self {
114            root: root.into(),
115            directory,
116            record,
117            bytes,
118            reserved: false,
119        };
120        slot.check()?;
121        Ok(slot)
122    }
123    fn released_fence(root: &Path, directory: &File, record: &Record, bytes: &[u8]) -> Result<u64> {
124        let old: Record = serde_json::from_value(canonical::parse_canonical(bytes)?)
125            .map_err(|_| Invalid("invalid service slot record"))?;
126        old.validate()?;
127        if !old.released
128            || old.resolve.binding.project_id != record.resolve.binding.project_id
129            || old.resolve.binding.resource_id != record.resolve.binding.resource_id
130        {
131            return Err(Invalid(
132                "service slot retained or belongs to another resource",
133            ));
134        }
135        // Never trust a released boolean alone. Revalidate the exact reserved
136        // journal, immutable proof and matching durable tombstone under lock.
137        let mut journal =
138            Journal::open_reserved(&root.join(old.journal_name()), &old.resolve, &old.plan)?;
139        journal.finish_release()?;
140        let archive = format!("service-history-{}.json", old.fence);
141        match Journal::read(directory, &archive)? {
142            Some(prior) if prior != bytes => return Err(Invalid("slot history changed")),
143            Some(_) => {}
144            None => Journal::atomic(directory, &archive, bytes)?,
145        }
146        let fence = old
147            .fence
148            .checked_add(1)
149            .ok_or(Invalid("slot fence exhausted"))?;
150        Ok(fence)
151    }
152    pub fn check(&self) -> Result<()> {
153        let now = io(fs::symlink_metadata(&self.root))?;
154        let held = io(self.directory.metadata())?;
155        private(&now, true)?;
156        if now.dev() != held.dev()
157            || now.ino() != held.ino()
158            || Journal::read(&self.directory, "service.json")?.as_deref()
159                != Some(self.bytes.as_slice())
160        {
161            return Err(Invalid("service slot changed outside lock"));
162        }
163        Ok(())
164    }
165    pub fn resolve_request(&self) -> &Request {
166        &self.record.resolve
167    }
168    pub fn plan(&self) -> &CreatePlan {
169        &self.record.plan
170    }
171    pub fn healthcheck(&self) -> &[String] {
172        &self.record.healthcheck
173    }
174    pub fn fence(&self) -> u64 {
175        self.record.fence
176    }
177    pub fn reserve(&mut self) -> Result<Reservation> {
178        self.check()?;
179        if self.reserved || self.record.released {
180            return Err(Invalid("slot reservation already consumed"));
181        }
182        self.reserved = true;
183        Reservation::create(
184            &self.root.join(self.record.journal_name()),
185            &self.record.resolve,
186            self.record.plan.clone(),
187        )
188    }
189    pub fn finish(&mut self, mut journal: Journal) -> Result<()> {
190        self.check()?;
191        let proof = journal
192            .record
193            .resolution
194            .as_ref()
195            .ok_or(Invalid("slot requires resolution proof"))?;
196        if journal.root != self.root.join(self.record.journal_name())
197            || proof.request != self.record.resolve
198            || proof.plan != self.record.plan
199        {
200            return Err(Invalid("journal does not belong to service slot"));
201        }
202        journal.finish_release()?;
203        let mut released = self.record.clone();
204        released.released = true;
205        let bytes = released.bytes()?;
206        Journal::atomic(&self.directory, "service.json", &bytes)?;
207        self.record = released;
208        self.bytes = bytes;
209        Ok(())
210    }
211}
212
213#[cfg(test)]
214mod tests;
215
216mod mapping;
217mod recovery;
218pub use recovery::OperatorRecovery;