1use super::{
4 state::{Event, Phase, State},
5 wire::{self, Binding, ContainerState, Operation, Outcome, Request, Response, Target},
6};
7use crate::{Invalid, Result};
8use serde::{Deserialize, Serialize};
9
10#[cfg(unix)]
11mod storage;
12#[cfg(unix)]
13pub use storage::{
14 Dispatch, Journal, OperatorRecovery, Reservation, ResolutionDispatch, ServiceSlot,
15};
16
17mod resolution;
18pub use resolution::CreatePlan;
19use resolution::ResolutionProof;
20
21const SCHEMA: &str = "gatehouse-lifecycle-lease/v1";
22const MAX_ATTEMPTS: usize = 128;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "kebab-case")]
28pub enum Purpose {
29 Service,
30 ServiceCleanup,
31 OperatorCleanup,
32 Reconcile,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37struct Pending {
38 request: Request,
39 purpose: Purpose,
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44struct Attempt {
45 request: Request,
46 purpose: Purpose,
47 response: Option<Response>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "kebab-case")]
53enum AbsenceReason {
54 OperatorObservedAbsence,
55 PostRemoveAbsence,
56}
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59struct AbsenceProof {
60 reason: AbsenceReason,
61 remove_request_sha256: Option<String>,
62 status_request_sha256: String,
63 response: Response,
64}
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67struct Record {
68 schema: String,
69 create: Request,
70 resolution: Option<ResolutionProof>,
71 state: State,
72 revision: u64,
73 pending: Option<Pending>,
74 attempts: Vec<Attempt>,
75 absence: Option<AbsenceProof>,
76 recoveries: Vec<u64>,
77}
78fn same_authority(a: &Binding, b: &Binding) -> bool {
79 let mut a = a.clone();
80 a.invocation = b.invocation.clone();
81 a.operation_key = b.operation_key.clone();
82 a.generation = b.generation;
83 a == *b
84}
85impl Record {
86 fn new(create: &Request) -> Result<Self> {
87 Self::new_resolved(create, None)
88 }
89 fn new_resolved(create: &Request, resolution: Option<ResolutionProof>) -> Result<Self> {
90 create.validate()?;
91 if !matches!(create.operation, Operation::Create { .. })
92 || create.binding.generation != if resolution.is_some() { 2 } else { 1 }
93 {
94 return Err(Invalid("journal requires first create generation"));
95 }
96 if let Some(proof) = &resolution {
97 proof.validate_create(create)?;
98 }
99 Ok(Self {
100 schema: SCHEMA.into(),
101 create: create.clone(),
102 resolution,
103 state: State::pending_create(),
104 revision: create.binding.generation,
105 pending: Some(Pending {
106 request: create.clone(),
107 purpose: Purpose::Service,
108 }),
109 attempts: vec![],
110 absence: None,
111 recoveries: vec![],
112 })
113 }
114 fn marker(&self) -> &str {
115 self.resolution
116 .as_ref()
117 .map(|p| p.request.binding.operation_key.as_str())
118 .unwrap_or(&self.create.binding.operation_key)
119 }
120 fn target(&self) -> Result<Target> {
121 let Operation::Create {
122 name,
123 labels,
124 container_port,
125 ..
126 } = &self.create.operation
127 else {
128 return Err(Invalid("invalid journal create"));
129 };
130 Ok(Target {
131 object_id: self
132 .state
133 .object_id()
134 .ok_or(Invalid("journal object unresolved"))?
135 .into(),
136 name: name.clone(),
137 labels: labels.clone(),
138 container_port: *container_port,
139 })
140 }
141 fn check_request(&self, r: &Request) -> Result<()> {
142 r.validate()?;
143 if !same_authority(&r.binding, &self.create.binding) {
144 return Err(Invalid("journal authority mismatch"));
145 }
146 let target = match &r.operation {
147 Operation::Status { target }
148 | Operation::Start { target }
149 | Operation::Stop { target, .. }
150 | Operation::Remove { target }
151 | Operation::HealthExec { target, .. } => target,
152 _ => return Err(Invalid("journal operation unsupported after creation")),
153 };
154 if *target != self.target()? {
155 return Err(Invalid("journal target mismatch"));
156 }
157 Ok(())
158 }
159 fn validate(&self) -> Result<()> {
160 if self.schema != SCHEMA || self.attempts.len() > MAX_ATTEMPTS {
161 return Err(Invalid("invalid lifecycle journal"));
162 }
163 self.create.validate()?;
164 self.state.validate()?;
165 if !matches!(self.create.operation, Operation::Create { .. })
166 || self.create.binding.generation != if self.resolution.is_some() { 2 } else { 1 }
167 {
168 return Err(Invalid("invalid journal origin"));
169 }
170 self.validate_history()?;
171 self.validate_absence()?;
172 self.replay()?;
173 Ok(())
174 }
175 fn validate_history(&self) -> Result<()> {
176 let base = self.create.binding.generation - 1;
177 let mut revision = base;
178 let mut nonces = std::collections::BTreeSet::new();
179 if let Some(proof) = &self.resolution {
180 proof.validate_create(&self.create)?;
181 nonces.insert(&proof.request.binding.invocation);
182 }
183 for a in &self.attempts {
184 if a.request.binding.generation != revision + 1
185 || !nonces.insert(&a.request.binding.invocation)
186 {
187 return Err(Invalid("journal generation or nonce replay"));
188 }
189 a.request.validate()?;
190 if revision == base {
191 if a.request != self.create || a.purpose != Purpose::Service {
192 return Err(Invalid("journal creation history mismatch"));
193 }
194 match a.response.as_ref().map(|r| &r.outcome) {
195 Some(Outcome::Created { object })
196 if self.state.object_id() == Some(object.object_id.as_str()) => {}
197 None | Some(Outcome::Unknown { .. } | Outcome::RejectedBeforeEffect { .. })
198 if self.state.object_id().is_none() => {}
199 _ => return Err(Invalid("journal object has no creation evidence")),
200 }
201 } else {
202 self.check_request(&a.request)?;
203 }
204 if !same_authority(&a.request.binding, &self.create.binding) {
205 return Err(Invalid("journal historical authority mismatch"));
206 }
207 if let Some(r) = &a.response {
208 r.validate(&a.request)?;
209 }
210 revision += 1;
211 }
212 revision = self.validate_pending(base, revision, &mut nonces)?;
213 if revision != self.revision {
214 return Err(Invalid("journal revision mismatch"));
215 }
216 Ok(())
217 }
218 fn validate_pending<'a>(
219 &'a self,
220 base: u64,
221 mut revision: u64,
222 nonces: &mut std::collections::BTreeSet<&'a String>,
223 ) -> Result<u64> {
224 if let Some(p) = &self.pending {
225 if p.request.binding.generation != revision + 1
226 || !nonces.insert(&p.request.binding.invocation)
227 {
228 return Err(Invalid("journal pending replay"));
229 }
230 p.request.validate()?;
231 if revision == base {
232 if p.request != self.create
233 || p.purpose != Purpose::Service
234 || self.state.phase() != Phase::PendingCreate
235 {
236 return Err(Invalid("journal initial intent mismatch"));
237 }
238 } else {
239 self.check_request(&p.request)?;
240 }
241 if !same_authority(&p.request.binding, &self.create.binding) {
242 return Err(Invalid("journal pending authority mismatch"));
243 }
244 revision += 1;
245 }
246 Ok(revision)
247 }
248 fn validate_absence(&self) -> Result<()> {
249 if self.state.phase() == Phase::VerifiedAbsent {
250 let a = self
251 .attempts
252 .last()
253 .ok_or(Invalid("missing absence attempt"))?;
254 if a.purpose == Purpose::Service
255 || (a.purpose == Purpose::Reconcile
256 && !self
257 .attempts
258 .iter()
259 .any(|old| matches!(old.request.operation, Operation::Remove { .. })))
260 || self.pending.is_some()
261 || self.absence.as_ref().map(|p| &p.response) != a.response.as_ref()
262 || !matches!(
263 self.absence.as_ref().map(|p| &p.response.outcome),
264 Some(Outcome::Absent { .. })
265 )
266 {
267 return Err(Invalid("missing journal absence evidence"));
268 }
269 } else if self.absence.is_some() {
270 return Err(Invalid("premature absence evidence"));
271 }
272 Ok(())
273 }
274 fn replay(&self) -> Result<()> {
275 if self.recoveries.windows(2).any(|w| w[0] >= w[1])
276 || self.recoveries.iter().any(|g| {
277 *g < self.create.binding.generation
278 || *g > self.attempts.len() as u64 + self.create.binding.generation - 1
279 })
280 {
281 return Err(Invalid("invalid journal recovery history"));
282 }
283 let mut replay = Self::new_resolved(&self.create, self.resolution.clone())?;
284 let mut fresh = false;
285 for (index, a) in self.attempts.iter().enumerate() {
286 if index > 0 {
287 replay = replay.begin_inner(&a.request, a.purpose, fresh)?;
288 }
289 let bytes = a
290 .response
291 .as_ref()
292 .map(|r| {
293 super::canonical::encode(
294 &serde_json::to_value(r).map_err(|_| Invalid("invalid stored response"))?,
295 )
296 })
297 .transpose()?;
298 replay = replay.observe_inner(bytes.as_deref())?;
299 fresh = matches!(
300 a.response.as_ref().map(|r| (&r.operation, &r.outcome)),
301 Some((Operation::Status { .. }, Outcome::Status { .. }))
302 );
303 if self.recoveries.contains(&replay.revision) {
304 replay.state = replay.state.recover()?;
305 replay.recoveries.push(replay.revision);
306 fresh = false;
307 }
308 }
309 if !self.attempts.is_empty() {
310 if let Some(p) = &self.pending {
311 replay = replay.begin_inner(&p.request, p.purpose, fresh)?;
312 }
313 }
314 if replay != *self {
315 return Err(Invalid(
316 "journal state does not follow its evidence history",
317 ));
318 }
319 Ok(())
320 }
321 fn begin(&self, r: &Request, purpose: Purpose, fresh_status: bool) -> Result<Self> {
322 self.validate()?;
323 let next = self.begin_inner(r, purpose, fresh_status)?;
324 next.validate()?;
325 Ok(next)
326 }
327 fn normal_cleanup(&self) -> bool {
328 self.state.phase() == Phase::Running
329 || (self.state.phase() == Phase::Stopped
330 && self
331 .attempts
332 .iter()
333 .rev()
334 .find(|a| !matches!(a.request.operation, Operation::Status { .. }))
335 .is_some_and(|a| {
336 a.purpose == Purpose::ServiceCleanup
337 && matches!(
338 a.response.as_ref().map(|r| &r.outcome),
339 Some(Outcome::Stopped { .. })
340 )
341 }))
342 }
343 fn begin_inner(&self, r: &Request, purpose: Purpose, fresh_status: bool) -> Result<Self> {
344 self.check_request(r)?;
345 if self.pending.is_some()
346 || self.attempts.len() >= MAX_ATTEMPTS
347 || self.state.phase() == Phase::VerifiedAbsent
348 || r.binding.generation
349 != self
350 .revision
351 .checked_add(1)
352 .ok_or(Invalid("generation overflow"))?
353 || self
354 .attempts
355 .iter()
356 .any(|a| a.request.binding.invocation == r.binding.invocation)
357 {
358 return Err(Invalid("journal request replay or unfinished attempt"));
359 }
360 let mut next = self.clone();
361 next.state = self.begin_state(r, purpose, fresh_status)?;
362 next.revision = r.binding.generation;
363 next.pending = Some(Pending {
364 request: r.clone(),
365 purpose,
366 });
367 Ok(next)
368 }
369 fn cleanup_authorized(&self, purpose: Purpose) -> bool {
370 purpose == Purpose::OperatorCleanup
371 || (purpose == Purpose::ServiceCleanup && self.normal_cleanup())
372 }
373 fn begin_state(&self, r: &Request, purpose: Purpose, fresh_status: bool) -> Result<State> {
374 let status = self
375 .attempts
376 .last()
377 .filter(|_| fresh_status)
378 .and_then(|a| a.response.as_ref())
379 .filter(|r| matches!(r.operation, Operation::Status { .. }));
380 let observed = match status.map(|r| &r.outcome) {
381 Some(Outcome::Status { object }) => Some(object.state),
382 _ => None,
383 };
384 let state = match &r.operation {
385 Operation::Status { .. } => {
386 if matches!(
387 self.state.phase(),
388 Phase::Retained | Phase::ReconcilingRemove
389 ) && matches!(purpose, Purpose::Service | Purpose::ServiceCleanup)
390 {
391 return Err(Invalid("recovered lease requires explicit reconciliation"));
392 }
393 self.state.clone()
394 }
395 Operation::Start { .. }
396 if purpose == Purpose::Service && observed == Some(ContainerState::Created) =>
397 {
398 self.state.transition(Event::BeginStart)?
399 }
400 Operation::HealthExec { .. }
401 if purpose == Purpose::Service && observed == Some(ContainerState::Running) =>
402 {
403 self.state.transition(Event::BeginHealth)?
404 }
405 Operation::Stop { .. }
406 if self.cleanup_authorized(purpose)
407 && observed == Some(ContainerState::Running) =>
408 {
409 let ev = if self.state.phase() == Phase::ReconcilingRemove {
410 Event::OperatorCleanupRunningVerified
411 } else {
412 Event::CleanupRunningVerified
413 };
414 self.state.transition(ev)?
415 }
416 Operation::Remove { .. }
417 if self.cleanup_authorized(purpose)
418 && matches!(
419 observed,
420 Some(ContainerState::Created | ContainerState::Exited)
421 ) =>
422 {
423 let stopped = if self.state.phase() == Phase::Stopped {
424 self.state.clone()
425 } else {
426 self.state
427 .transition(if self.state.phase() == Phase::ReconcilingRemove {
428 Event::OperatorCleanupTerminalVerified
429 } else {
430 Event::CleanupTerminalVerified
431 })?
432 };
433 stopped.transition(Event::BeginRemove)?
434 }
435 _ => {
436 return Err(Invalid(
437 "journal mutation lacks fresh evidence or authorization",
438 ))
439 }
440 };
441 Ok(state)
442 }
443 fn observe(&self, bytes: Option<&[u8]>) -> Result<Self> {
444 self.validate()?;
445 let next = self.observe_inner(bytes)?;
446 next.validate()?;
447 Ok(next)
448 }
449 fn observe_inner(&self, bytes: Option<&[u8]>) -> Result<Self> {
450 let pending = self
451 .pending
452 .as_ref()
453 .ok_or(Invalid("no journal request pending"))?;
454 let response = bytes
455 .map(|b| wire::parse_response(b, &pending.request))
456 .transpose()?;
457 let mut next = self.clone();
458 let outcome = response.as_ref().map(|r| &r.outcome);
459 self.validate_observed_image(outcome)?;
460 next.state = match (&pending.request.operation, outcome) {
461 (_, None | Some(Outcome::Unknown { .. } | Outcome::RejectedBeforeEffect { .. })) => {
462 self.state.recover()?
463 }
464 (Operation::Create { .. }, Some(Outcome::Created { object })) => self
465 .state
466 .transition(Event::Created(object.object_id.clone()))?,
467 (Operation::Status { .. }, Some(Outcome::Status { .. })) => {
468 if matches!(self.state.phase(), Phase::AwaitingAbsence | Phase::Removing) {
469 if !matches!(
470 pending.purpose,
471 Purpose::OperatorCleanup | Purpose::Reconcile
472 ) {
473 return Err(Invalid("present removal requires reconciliation purpose"));
474 }
475 self.state.recover()?
476 } else {
477 self.state.clone()
478 }
479 }
480 (Operation::Status { .. }, Some(Outcome::Absent { .. })) => {
481 let (state, proof) = self.observe_absence(
482 pending,
483 response
484 .clone()
485 .ok_or(Invalid("missing absence response"))?,
486 )?;
487 next.absence = Some(proof);
488 state
489 }
490 (Operation::Start { .. }, Some(Outcome::Started { .. })) => {
491 self.state.transition(Event::RunningVerified)?
492 }
493 (Operation::Stop { .. }, Some(Outcome::Stopped { .. })) => {
494 self.state.transition(Event::StoppedVerified)?
495 }
496 (Operation::Remove { .. }, Some(Outcome::Removed)) => {
497 self.state.transition(Event::RemoveClaimed)?
498 }
499 (Operation::HealthExec { .. }, Some(Outcome::Health { exit_code: 0, .. })) => {
500 self.state.transition(Event::HealthVerified)?
501 }
502 (Operation::HealthExec { .. }, Some(Outcome::Health { .. })) => {
503 self.state.transition(Event::Failed)?
504 }
505 _ => return Err(Invalid("journal outcome cannot advance state")),
506 };
507 next.attempts.push(Attempt {
508 request: pending.request.clone(),
509 purpose: pending.purpose,
510 response,
511 });
512 next.pending = None;
513 Ok(next)
514 }
515 fn observe_absence(
516 &self,
517 pending: &Pending,
518 response: Response,
519 ) -> Result<(State, AbsenceProof)> {
520 let ev = match (self.state.phase(), pending.purpose) {
521 (
522 Phase::AwaitingAbsence | Phase::ReconcilingRemove,
523 Purpose::Reconcile | Purpose::OperatorCleanup,
524 ) => Event::AbsenceVerified,
525 (Phase::Retained, Purpose::OperatorCleanup) => Event::OperatorAbsenceVerified,
526 _ => return Err(Invalid("absence lacks reconciliation authorization")),
527 };
528 let remove = self
529 .attempts
530 .iter()
531 .rev()
532 .find(|a| matches!(a.request.operation, Operation::Remove { .. }))
533 .map(|a| a.request.binding.operation_key.clone());
534 let proof = AbsenceProof {
535 reason: if remove.is_some() {
536 AbsenceReason::PostRemoveAbsence
537 } else {
538 AbsenceReason::OperatorObservedAbsence
539 },
540 remove_request_sha256: remove,
541 status_request_sha256: pending.request.binding.operation_key.clone(),
542 response,
543 };
544 Ok((self.state.transition(ev)?, proof))
545 }
546 fn validate_observed_image(&self, outcome: Option<&Outcome>) -> Result<()> {
547 if let Some(
549 Outcome::Created { object }
550 | Outcome::Status { object }
551 | Outcome::Started { object }
552 | Outcome::Stopped { object }
553 | Outcome::Health { object, .. },
554 ) = outcome
555 {
556 if let Operation::Create { image_id, .. } = &self.create.operation {
557 if &object.image_id != image_id {
558 return Err(Invalid("journal image changed"));
559 }
560 }
561 }
562 Ok(())
563 }
564 fn recover(&self) -> Result<Self> {
565 let mut next = if self.pending.is_some() {
566 self.observe(None)?
567 } else {
568 self.clone()
569 };
570 let recovered = next.state.recover()?;
571 if recovered != next.state {
572 next.recoveries.push(next.revision);
573 next.state = recovered;
574 }
575 next.validate()?;
576 Ok(next)
577 }
578}