gatehouse_runtime_protocol/lifecycle/journal/storage/slot/
recovery.rs1use super::*;
3use sha2::{Digest, Sha256};
4
5pub struct OperatorRecovery {
6 slot: ServiceSlot,
7 journal: Journal,
8 authorization: String,
9}
10impl OperatorRecovery {
11 pub fn open_mapped(
14 base: &Path,
15 project: &str,
16 resource: &str,
17 fence: u64,
18 lease_invocation: &str,
19 authorization: &str,
20 ) -> Result<Self> {
21 if authorization.len() != 32
22 || !authorization
23 .bytes()
24 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
25 {
26 return Err(Invalid(
27 "operator authorization must be fresh 128-bit identity",
28 ));
29 }
30 let _parent = Journal::directory(base)?;
31 let root = ServiceSlot::mapped_root(base, project, resource)?;
32 let directory = Journal::directory(&root)?;
33 let bytes =
34 Journal::read(&directory, "service.json")?.ok_or(Invalid("missing service slot"))?;
35 let record: Record = serde_json::from_value(canonical::parse_canonical(&bytes)?)
36 .map_err(|_| Invalid("invalid operator slot record"))?;
37 record.validate()?;
38 if record.released {
39 return Err(Invalid("released slot does not require operator recovery"));
40 }
41 if record.fence != fence
42 || record.resolve.binding.lease_invocation != lease_invocation
43 || record.resolve.binding.project_id != project
44 || record.resolve.binding.resource_id != resource
45 {
46 return Err(Invalid(
47 "operator authorization does not match current resource fence",
48 ));
49 }
50 let slot = ServiceSlot {
51 root,
52 directory,
53 record,
54 bytes,
55 reserved: true,
56 };
57 slot.check()?;
58 let journal = Journal::open_reserved(
59 &slot.root.join(slot.record.journal_name()),
60 &slot.record.resolve,
61 &slot.record.plan,
62 )?;
63 journal.target()?;
65 let name = format!("operator-{authorization}.json");
66 if Journal::read(&slot.directory, &name)?.is_some() {
67 return Err(Invalid("operator authorization replay"));
68 }
69 let intent = canonical::encode(&serde_json::json!({
70 "schema":"gatehouse-lifecycle-operator/v1", "action":"cleanup-known-id",
71 "authorization":authorization, "fence":fence, "lease_invocation":lease_invocation,
72 "slot_sha256":format!("{:x}",Sha256::digest(&slot.bytes)),
73 "target":journal.target()?
74 }))?;
75 Journal::atomic(&slot.directory, &name, &intent)?;
76 Ok(Self {
77 slot,
78 journal,
79 authorization: authorization.into(),
80 })
81 }
82 pub fn resolve_request(&self) -> &Request {
83 self.slot.resolve_request()
84 }
85 pub fn target(&self) -> Result<Target> {
86 self.journal.target()
87 }
88 pub fn phase(&self) -> Phase {
89 self.journal.phase()
90 }
91 pub fn check(&self) -> Result<()> {
92 self.slot.check()
93 }
94 pub fn begin(&mut self, operation: Operation, invocation: &str) -> Result<Dispatch<'_>> {
95 self.slot.check()?;
96 if !matches!(
97 operation,
98 Operation::Status { .. } | Operation::Stop { .. } | Operation::Remove { .. }
99 ) {
100 return Err(Invalid(
101 "operator recovery forbids start/create/health/resolution",
102 ));
103 }
104 let mut request = self.journal.record.create.clone();
105 request.operation = operation;
106 request.binding.generation = self
107 .journal
108 .revision()
109 .checked_add(1)
110 .ok_or(Invalid("operator generation exhausted"))?;
111 request.binding.invocation = invocation.into();
112 request.binding.operation_key = request.operation_key()?;
113 self.journal.begin(&request, Purpose::OperatorCleanup)
114 }
115 pub fn finish(self) -> Result<()> {
116 self.finish_with(|| Ok(()))
117 }
118 fn finish_with(mut self, after_proof: impl FnOnce() -> Result<()>) -> Result<()> {
119 self.slot.check()?;
120 self.journal.finish_release()?;
121 let evidence = canonical::encode(&serde_json::json!({
122 "schema":"gatehouse-lifecycle-operator-proof/v1", "authorization":self.authorization,
123 "fence":self.slot.fence(), "slot_sha256":format!("{:x}",Sha256::digest(&self.slot.bytes)),
124 "journal_sha256":format!("{:x}",Sha256::digest(&self.journal.bytes)),
125 "target":self.journal.target()?
126 }))?;
127 Journal::atomic(
128 &self.slot.directory,
129 &format!("operator-{}.proof.json", self.authorization),
130 &evidence,
131 )?;
132 after_proof()?;
133 self.slot.finish(self.journal)
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use crate::lifecycle::{
141 journal::storage::tests::{absent, object, reply},
142 wire::ContainerState,
143 };
144
145 #[test]
146 fn persisted_operator_proof_never_substitutes_for_slot_release() {
147 let (d, resolve, plan) = super::super::tests::fixture();
148 let mut slot =
149 ServiceSlot::acquire_mapped(d.path(), resolve.clone(), plan.clone(), vec![]).unwrap();
150 let root = slot.root.clone();
151 let mut reservation = slot.reserve().unwrap();
152 let image = reply(
153 &resolve,
154 Outcome::Image {
155 image_id: format!("sha256:{}", "e".repeat(64)),
156 repo_digests: vec![],
157 },
158 );
159 reservation
160 .dispatch()
161 .unwrap()
162 .observe(Some(&image))
163 .unwrap();
164 let mut journal = reservation.into_journal(&"3".repeat(32)).unwrap();
165 let token = journal.dispatch().unwrap();
166 let create = token.request().clone();
167 let created = reply(
168 &create,
169 Outcome::Created {
170 object: object(&create, ContainerState::Created),
171 },
172 );
173 token.observe(Some(&created)).unwrap();
174 drop(journal);
175 drop(slot);
176 let open = |auth: &str| {
177 OperatorRecovery::open_mapped(
178 d.path(),
179 &resolve.binding.project_id,
180 &resolve.binding.resource_id,
181 1,
182 &resolve.binding.lease_invocation,
183 auth,
184 )
185 };
186 let first = "9".repeat(32);
187 let mut recovery = open(&first).unwrap();
188 let target = recovery.target().unwrap();
189 let token = recovery
190 .begin(Operation::Status { target }, &"4".repeat(32))
191 .unwrap();
192 let status = token.request().clone();
193 token.observe(Some(&absent(&status))).unwrap();
194 let before = fs::read(root.join("service.json")).unwrap();
195 let failure = recovery.finish_with(|| {
196 assert!(root.join(format!("operator-{first}.proof.json")).exists());
197 Err(Invalid("injected failure after proof before slot release"))
198 });
199 assert!(failure
200 .unwrap_err()
201 .to_string()
202 .contains("injected failure"));
203 assert_eq!(fs::read(root.join("service.json")).unwrap(), before);
204 let journal_root = root.join(format!("journal-{}", resolve.binding.lease_invocation));
205 assert!(journal_root.join("tombstone.json").exists());
206 assert!(!journal_root.join("active").exists());
207 assert!(ServiceSlot::acquire_mapped(d.path(), resolve.clone(), plan, vec![]).is_err());
208 assert!(open(&first).err().unwrap().to_string().contains("replay"));
209 let second = "8".repeat(32);
210 let recovery = open(&second).unwrap();
211 assert_eq!(recovery.phase(), Phase::VerifiedAbsent);
212 recovery.finish().unwrap();
214 let record: Record =
215 serde_json::from_slice(&fs::read(root.join("service.json")).unwrap()).unwrap();
216 assert!(record.released);
217 assert!(root.join(format!("operator-{second}.proof.json")).exists());
218 assert!(open(&"7".repeat(32))
219 .err()
220 .unwrap()
221 .to_string()
222 .contains("released slot"));
223 }
224}