refactor: move firmware workspace under firmware
This commit is contained in:
1 parent
153b0bf262
commit
f03fc119a4
222 files changed
+56
-13
No files matched your search
@@ -0,0 +1,420 @@
|
||||
//! Transactional committed-versus-active configuration lifecycle.
|
||||
|
||||
use crate::{
|
||||
ConfigModel, ConfigValue, ConfigViolation, FieldDescriptor, FieldId, FieldWriteError,
|
||||
Generation, ProfileStore,
|
||||
};
|
||||
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 reboot.
|
||||
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 profile, 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,
|
||||
committed: C,
|
||||
transaction: Option<C>,
|
||||
context: C::Context,
|
||||
active_generation: Option<Generation>,
|
||||
committed_generation: Option<Generation>,
|
||||
store: S,
|
||||
restart_required: bool,
|
||||
}
|
||||
|
||||
impl<C, S> ConfigManager<C, S>
|
||||
where
|
||||
C: ConfigModel,
|
||||
S: ProfileStore<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,
|
||||
committed: factory,
|
||||
transaction: None,
|
||||
context,
|
||||
active_generation: None,
|
||||
committed_generation: None,
|
||||
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((generation, 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,
|
||||
committed,
|
||||
transaction: None,
|
||||
context,
|
||||
active_generation: Some(generation),
|
||||
committed_generation: Some(generation),
|
||||
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 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
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the boot-active snapshot generation.
|
||||
pub const fn active_generation(&self) -> Option<Generation> {
|
||||
self.active_generation
|
||||
}
|
||||
|
||||
/// Returns the most recently recovered or committed generation.
|
||||
pub const fn committed_generation(&self) -> Option<Generation> {
|
||||
self.committed_generation
|
||||
}
|
||||
|
||||
/// Returns the most recently recovered or committed generation.
|
||||
///
|
||||
/// Retained as a concise alias for optimistic commit callers.
|
||||
pub const fn generation(&self) -> Option<Generation> {
|
||||
self.committed_generation
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Replaces the open RAM transaction with one complete imported candidate.
|
||||
///
|
||||
/// Unlike scalar edits, imports are accepted only after complete
|
||||
/// relational and capability validation. Neither the boot-active nor the
|
||||
/// committed snapshot is changed here.
|
||||
pub fn replace_transaction(&mut self, candidate: C) -> Result<(), CommitError<S::Error>> {
|
||||
candidate
|
||||
.validate(&self.context)
|
||||
.map_err(CommitError::InvalidCandidate)?;
|
||||
let transaction = self
|
||||
.transaction
|
||||
.as_mut()
|
||||
.ok_or(CommitError::NoTransaction)?;
|
||||
*transaction = candidate;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validates and durably commits the complete open transaction.
|
||||
pub fn commit(&mut self) -> Result<Generation, CommitError<S::Error>> {
|
||||
let candidate = self
|
||||
.transaction
|
||||
.as_ref()
|
||||
.ok_or(CommitError::NoTransaction)?;
|
||||
candidate
|
||||
.validate(&self.context)
|
||||
.map_err(CommitError::InvalidCandidate)?;
|
||||
let generation = self.store.persist(candidate).map_err(CommitError::Store)?;
|
||||
self.committed = candidate.clone();
|
||||
self.transaction = None;
|
||||
self.committed_generation = Some(generation);
|
||||
self.restart_required = true;
|
||||
Ok(generation)
|
||||
}
|
||||
|
||||
/// Drops the RAM transaction without changing active or committed state.
|
||||
pub fn abort(&mut self) -> bool {
|
||||
self.transaction.take().is_some()
|
||||
}
|
||||
|
||||
/// Releases the owned store when the configuration owner shuts down.
|
||||
pub fn into_store(self) -> S {
|
||||
self.store
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{ApplyPolicy, 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)),
|
||||
default: ConfigValue::U32(2),
|
||||
unit: "count",
|
||||
apply_policy: ApplyPolicy::RestartRequired,
|
||||
},
|
||||
FieldDescriptor {
|
||||
id: OTHER,
|
||||
display_key: "test.other",
|
||||
kind: ValueKind::U32,
|
||||
minimum: Some(ConfigValue::U32(1)),
|
||||
maximum: Some(ConfigValue::U32(10)),
|
||||
default: ConfigValue::U32(4),
|
||||
unit: "count",
|
||||
apply_policy: ApplyPolicy::RestartRequired,
|
||||
},
|
||||
];
|
||||
|
||||
#[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 ProfileStore<Candidate> for MemoryStore {
|
||||
type Error = ();
|
||||
|
||||
fn load(&mut self) -> Result<Option<(Generation, Candidate)>, Self::Error> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn persist(&mut self, _: &Candidate) -> Result<Generation, Self::Error> {
|
||||
Ok(Generation::new(7))
|
||||
}
|
||||
}
|
||||
|
||||
#[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(Generation::new(7)));
|
||||
assert_eq!(manager.active(), &Active { value: 2, other: 4 });
|
||||
assert_eq!(manager.committed_value(VALUE), Some(ConfigValue::U32(3)));
|
||||
assert_eq!(manager.active_generation(), None);
|
||||
assert_eq!(manager.committed_generation(), Some(Generation::new(7)));
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user