Files
kaptan/firmware/libs/kaptan-config/src/lifecycle.rs
T

437 lines
14 KiB
Rust

//! Transactional committed-versus-active configuration lifecycle.
use crate::{
ConfigModel, ConfigStore, ConfigValue, ConfigViolation, FieldDescriptor, FieldId,
FieldWriteError,
};
use kaptan_log::KaptanError;
/// Observable state of the bounded configuration transaction slot.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConfigState {
/// The boot-active and committed snapshots match and no edit is open.
Active,
/// One isolated RAM transaction is open.
Editing,
/// A newer committed snapshot will take effect after restart.
RestartRequired,
}
/// Failure while recovering a boot configuration from a store.
#[derive(Debug, KaptanError)]
pub enum StartupError<E> {
/// The storage backend could not provide a committed record.
Store(E),
/// A committed record did not validate against this firmware's schema.
InvalidPersisted(ConfigViolation),
}
/// Failure while beginning a transaction.
#[derive(Clone, Copy, Debug, Eq, PartialEq, KaptanError)]
pub enum BeginError {
/// Exactly one transaction is already open.
TransactionActive,
}
/// Failure while writing a scalar into the bounded transaction.
#[derive(Clone, Copy, Debug, PartialEq, KaptanError)]
pub enum SetError {
/// No transaction is open.
NoTransaction,
/// The candidate rejected the local scalar without mutation.
Field(FieldWriteError),
}
/// Failure while validating or committing a transaction.
#[derive(Debug, PartialEq, KaptanError)]
pub enum CommitError<E> {
/// No transaction is open.
NoTransaction,
/// The complete candidate violates a relational or capability invariant.
InvalidCandidate(ConfigViolation),
/// The storage backend could not commit the snapshot.
Store(E),
}
/// Owns the immutable boot-active configuration, the latest durable candidate, and
/// exactly one RAM edit transaction.
///
/// P1 deliberately has no activation operation: [`Self::active`] never
/// changes after boot. `commit` atomically persists a complete validated
/// candidate, then reports [`ConfigState::RestartRequired`].
pub struct ConfigManager<C: ConfigModel, S> {
active: C::Active,
factory: C,
committed: C,
transaction: Option<C>,
context: C::Context,
store: S,
restart_required: bool,
}
impl<C, S> ConfigManager<C, S>
where
C: ConfigModel,
S: ConfigStore<C>,
{
/// Starts from a validated factory candidate when no persistent record is
/// available. Flight callers separately decide whether setup/calibration
/// is sufficient to arm; no recovery is implicit.
pub fn new(factory: C, context: C::Context, store: S) -> Result<Self, ConfigViolation> {
let active = factory.validate(&context)?;
Ok(Self {
active,
factory: factory.clone(),
committed: factory,
transaction: None,
context,
store,
restart_required: false,
})
}
/// Recovers the newest valid committed snapshot or uses the supplied
/// factory candidate when the store is empty.
pub fn recover(
factory: C,
context: C::Context,
mut store: S,
) -> Result<Self, StartupError<S::Error>> {
let Some(committed) = store.load().map_err(StartupError::Store)? else {
return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted);
};
let active = committed
.validate(&context)
.map_err(StartupError::InvalidPersisted)?;
Ok(Self {
active,
factory,
committed,
transaction: None,
context,
store,
restart_required: false,
})
}
/// Recovers the newest candidate compatible with the current context,
/// falling back to the supplied factory when no compatible record exists.
///
/// This is an explicit recovery policy for output-inhibited setup images.
/// Safety-critical compositions should use [`Self::recover`] if an
/// incompatible durable record must prevent initialization.
pub fn recover_compatible_or_factory(
factory: C,
context: C::Context,
mut store: S,
) -> Result<Self, StartupError<S::Error>> {
let Some(committed) = store
.load_matching(|candidate| candidate.validate(&context).is_ok())
.map_err(StartupError::Store)?
else {
return Self::new(factory, context, store).map_err(StartupError::InvalidPersisted);
};
let active = committed
.validate(&context)
.map_err(StartupError::InvalidPersisted)?;
Ok(Self {
active,
factory,
committed,
transaction: None,
context,
store,
restart_required: false,
})
}
/// Returns the immutable configuration selected at boot.
pub const fn active(&self) -> &C::Active {
&self.active
}
/// Returns the last durably committed candidate value.
pub fn committed_value(&self, id: FieldId) -> Option<ConfigValue> {
self.committed.field_value(id)
}
/// Returns the selected board/image factory value for descriptor metadata.
pub fn factory_value(&self, id: FieldId) -> Option<ConfigValue> {
self.factory.field_value(id)
}
/// Returns a value from the immutable boot-active snapshot.
pub fn active_value(&self, id: FieldId) -> Option<ConfigValue> {
C::active_field_value(&self.active, id)
}
/// Returns a transaction value when an edit is open.
pub fn transaction_value(&self, id: FieldId) -> Option<ConfigValue> {
self.transaction.as_ref()?.field_value(id)
}
/// Returns descriptor metadata by stable schema index.
pub fn descriptor(&self, index: u16) -> Option<&'static FieldDescriptor> {
C::descriptor(index)
}
/// Number of fields exposed by the compiled registry.
pub fn field_count(&self) -> u16 {
C::field_count()
}
/// Returns the current lifecycle state.
pub const fn state(&self) -> ConfigState {
if self.transaction.is_some() {
ConfigState::Editing
} else if self.restart_required {
ConfigState::RestartRequired
} else {
ConfigState::Active
}
}
/// Opens the only transaction slot from the last durably committed state.
pub fn begin(&mut self) -> Result<(), BeginError> {
if self.transaction.is_some() {
return Err(BeginError::TransactionActive);
}
self.transaction = Some(self.committed.clone());
Ok(())
}
/// Applies one locally valid scalar in the open transaction.
///
/// Cross-field-invalid intermediate states are allowed so multi-field
/// edits can be made naturally. They are rejected by `validate`/`commit`.
pub fn set(&mut self, id: FieldId, value: ConfigValue) -> Result<ConfigValue, SetError> {
let candidate = self.transaction.as_mut().ok_or(SetError::NoTransaction)?;
candidate.set_field(id, value).map_err(SetError::Field)?;
candidate
.field_value(id)
.ok_or(SetError::Field(FieldWriteError::UnknownField(id)))
}
/// Validates the complete open transaction without changing persistent or
/// active state.
pub fn validate(&self) -> Result<(), CommitError<S::Error>> {
let candidate = self
.transaction
.as_ref()
.ok_or(CommitError::NoTransaction)?;
candidate
.validate(&self.context)
.map(|_| ())
.map_err(CommitError::InvalidCandidate)
}
/// Validates and durably commits the complete open transaction.
pub fn commit(&mut self) -> Result<(), CommitError<S::Error>> {
let candidate = self
.transaction
.as_ref()
.ok_or(CommitError::NoTransaction)?;
candidate
.validate(&self.context)
.map_err(CommitError::InvalidCandidate)?;
self.store.persist(candidate).map_err(CommitError::Store)?;
self.committed = candidate.clone();
self.transaction = None;
self.restart_required = true;
Ok(())
}
/// Drops the RAM transaction without changing active or committed state.
pub fn abort(&mut self) -> bool {
self.transaction.take().is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ConfigValue, ValidationRule, ValueKind};
const VALUE: FieldId = FieldId::new(1);
const OTHER: FieldId = FieldId::new(2);
const DESCRIPTORS: [FieldDescriptor; 2] = [
FieldDescriptor {
id: VALUE,
display_key: "test.value",
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(10)),
enum_options: &[],
unit: "count",
},
FieldDescriptor {
id: OTHER,
display_key: "test.other",
kind: ValueKind::U32,
minimum: Some(ConfigValue::U32(1)),
maximum: Some(ConfigValue::U32(10)),
enum_options: &[],
unit: "count",
},
];
#[derive(Clone)]
struct Candidate {
value: u32,
other: u32,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Active {
value: u32,
other: u32,
}
impl ConfigModel for Candidate {
type Active = Active;
type Context = ();
const SCHEMA_ID: [u8; 16] = *b"CONFIGLIFECYCLE1";
fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
DESCRIPTORS.get(usize::from(index))
}
fn field_count() -> u16 {
DESCRIPTORS.len() as u16
}
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
match id {
VALUE => Some(ConfigValue::U32(self.value)),
OTHER => Some(ConfigValue::U32(self.other)),
_ => None,
}
}
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
match id {
VALUE => Some(ConfigValue::U32(active.value)),
OTHER => Some(ConfigValue::U32(active.other)),
_ => None,
}
}
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
let ConfigValue::U32(value) = value else {
return Err(FieldWriteError::WrongType {
field: id,
expected: ValueKind::U32,
actual: value.kind(),
});
};
if !(1..=10).contains(&value) {
return Err(FieldWriteError::Violation(ConfigViolation::field(
id,
ValidationRule::AboveMaximum,
)));
}
match id {
VALUE => self.value = value,
OTHER => self.other = value,
_ => return Err(FieldWriteError::UnknownField(id)),
}
Ok(())
}
fn validate(&self, _: &Self::Context) -> Result<Self::Active, ConfigViolation> {
if self.value > self.other {
return Err(ConfigViolation::related(
VALUE,
OTHER,
ValidationRule::Inconsistent,
));
}
Ok(Active {
value: self.value,
other: self.other,
})
}
}
struct MemoryStore;
impl ConfigStore<Candidate> for MemoryStore {
type Error = ();
fn load(&mut self) -> Result<Option<Candidate>, Self::Error> {
Ok(None)
}
fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> {
Ok(())
}
}
struct IncompatibleStore;
impl ConfigStore<Candidate> for IncompatibleStore {
type Error = ();
fn load(&mut self) -> Result<Option<Candidate>, Self::Error> {
Ok(Some(Candidate { value: 9, other: 1 }))
}
fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> {
Ok(())
}
}
#[test]
fn invalid_intermediate_transaction_never_changes_active_or_committed() {
let mut manager =
ConfigManager::new(Candidate { value: 2, other: 4 }, (), MemoryStore).unwrap();
manager.begin().unwrap();
assert_eq!(
manager.set(VALUE, ConfigValue::U32(5)),
Ok(ConfigValue::U32(5))
);
assert_eq!(
manager.validate(),
Err(CommitError::InvalidCandidate(ConfigViolation::related(
VALUE,
OTHER,
ValidationRule::Inconsistent
)))
);
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(2)));
assert_eq!(manager.transaction_value(VALUE), Some(ConfigValue::U32(5)));
assert_eq!(manager.state(), ConfigState::Editing);
}
#[test]
fn commit_keeps_active_immutable_and_abort_retains_committed_snapshot() {
let mut manager =
ConfigManager::new(Candidate { value: 2, other: 4 }, (), MemoryStore).unwrap();
manager.begin().unwrap();
manager.set(VALUE, ConfigValue::U32(3)).unwrap();
assert_eq!(manager.commit(), Ok(()));
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3)));
assert_eq!(manager.state(), ConfigState::RestartRequired);
manager.begin().unwrap();
manager.set(VALUE, ConfigValue::U32(4)).unwrap();
assert!(manager.abort());
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3)));
assert_eq!(manager.state(), ConfigState::RestartRequired);
}
#[test]
fn explicit_recovery_policy_uses_factory_when_no_record_matches_context() {
let factory = Candidate { value: 2, other: 4 };
assert!(matches!(
ConfigManager::recover(factory.clone(), (), IncompatibleStore),
Err(StartupError::InvalidPersisted(_))
));
let manager =
ConfigManager::recover_compatible_or_factory(factory, (), IncompatibleStore).unwrap();
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
}
}