Recover fenced rollback from current activation
This commit is contained in:
parent
02e27a1048
commit
a148802cb6
3 changed files with 188 additions and 12 deletions
|
|
@ -298,6 +298,15 @@ continuity restart does not fetch a branch, rebuild, rerun a migration, alter
|
|||
the launch contract, or consume the deployment brake. An explicit lifecycle
|
||||
brake may suspend this actuation without changing deployment authority.
|
||||
|
||||
An Expected digest stays stable across continuity restarts, so the current
|
||||
activation at that projection key can be newer than the activation cached in an
|
||||
older admitted-generation receipt. During post-fencing rollback, Idunn first
|
||||
stops the candidate and withdraws the lease. It then verifies any current
|
||||
projected activation against Idunn's own trust anchor and the exact Expected
|
||||
record, refuses if a projected write lease remains, and demotes the projection
|
||||
to Expected-only. The admitted receipt does not overwrite a newer signed
|
||||
activation, and no operator edits the CultCache projection by hand.
|
||||
|
||||
Idunn starts and recovers from its own durable admitted state. Odin is the first
|
||||
managed semantic daemon, never an Idunn bootstrap dependency. Odin's initial
|
||||
admission is the sole graph-bootstrap exception: Idunn uses its root-admitted
|
||||
|
|
|
|||
|
|
@ -5977,11 +5977,10 @@ impl Engine {
|
|||
if let Some(incumbent) = self.exact_incumbent(&snapshot, ¤t.value)? {
|
||||
let admitted_provider_anchor =
|
||||
self.provider_anchor_for_plan(&incumbent.value.plan)?;
|
||||
let expected_sha256 = topology.demote_to_expected_only(
|
||||
let expected_sha256 = topology.demote_current_activation_to_expected_only(
|
||||
&incumbent.value.expected,
|
||||
&admitted_provider_anchor,
|
||||
&incumbent.value.activation,
|
||||
incumbent.value.leasing.lease(),
|
||||
&self.idunn_anchor,
|
||||
)?;
|
||||
ensure!(
|
||||
expected_sha256 == incumbent.value.expected.canonical_sha256()?,
|
||||
|
|
|
|||
186
src/drivers.rs
186
src/drivers.rs
|
|
@ -4326,20 +4326,36 @@ impl CultCacheTopologyDriver {
|
|||
provider_anchor: &ServiceIdentityTrustAnchor,
|
||||
activation: &IdunnRuntimeActivationRecord,
|
||||
lease: Option<&IdunnProcessWriteLeaseRecord>,
|
||||
) -> Result<String> {
|
||||
self.demote_projection(expected, provider_anchor, Some(activation), lease)
|
||||
}
|
||||
|
||||
fn demote_projection(
|
||||
&self,
|
||||
expected: &IdunnExpectedIncarnationRecord,
|
||||
provider_anchor: &ServiceIdentityTrustAnchor,
|
||||
activation: Option<&IdunnRuntimeActivationRecord>,
|
||||
lease: Option<&IdunnProcessWriteLeaseRecord>,
|
||||
) -> Result<String> {
|
||||
expected.validate()?;
|
||||
activation.validate()?;
|
||||
ensure!(
|
||||
activation.expected_projection_sha256 == expected.canonical_sha256()?,
|
||||
"admitted activation does not bind the admitted Expected projection"
|
||||
);
|
||||
if let Some(activation) = activation {
|
||||
activation.validate()?;
|
||||
ensure!(
|
||||
activation.expected_projection_sha256 == expected.canonical_sha256()?,
|
||||
"admitted activation does not bind the admitted Expected projection"
|
||||
);
|
||||
}
|
||||
if let Some(lease) = lease {
|
||||
validate_topology_lease(expected, activation, lease)?;
|
||||
validate_topology_lease(
|
||||
expected,
|
||||
activation.context("demotion lease has no exact activation")?,
|
||||
lease,
|
||||
)?;
|
||||
}
|
||||
let anchor = runtime_presence_trust_anchor(expected, provider_anchor)?;
|
||||
let key = incarnation_key(expected)?;
|
||||
self.mutate(|entries| {
|
||||
let projected = ProjectedIncarnation::read(entries, expected, Some(activation), lease)?;
|
||||
let projected = ProjectedIncarnation::read(entries, expected, activation, lease)?;
|
||||
let current_anchor = projection_entry(
|
||||
entries,
|
||||
GameCultServiceTrustAnchorRecord::TYPE,
|
||||
|
|
@ -4380,6 +4396,55 @@ impl CultCacheTopologyDriver {
|
|||
expected.canonical_sha256()
|
||||
}
|
||||
|
||||
/// Demote the current projected activation after the owning transaction
|
||||
/// has fenced the target. A continuity restart can replace the activation
|
||||
/// under the same Expected key while the admitted-generation receipt still
|
||||
/// carries the prior runtime instance. In that one post-fence recovery
|
||||
/// path, trust the current projection only after verifying Idunn's
|
||||
/// signature and exact Expected binding, and refuse while any projected
|
||||
/// write lease remains.
|
||||
pub fn demote_current_activation_to_expected_only(
|
||||
&self,
|
||||
expected: &IdunnExpectedIncarnationRecord,
|
||||
provider_anchor: &ServiceIdentityTrustAnchor,
|
||||
idunn_anchor: &ServiceIdentityTrustAnchor,
|
||||
) -> Result<String> {
|
||||
expected.validate()?;
|
||||
let entries = self.snapshot()?;
|
||||
let key = incarnation_key(expected)?;
|
||||
let projected_activation = projection_entry(
|
||||
&entries,
|
||||
IdunnRuntimeActivationRecord::TYPE,
|
||||
&key,
|
||||
)?
|
||||
.map(|envelope| -> Result<IdunnRuntimeActivationRecord> {
|
||||
ensure!(
|
||||
envelope.schema_id.as_deref() == Some(IDUNN_RUNTIME_ACTIVATION_SCHEMA),
|
||||
"projected activation schema is substituted"
|
||||
);
|
||||
let activation = IdunnRuntimeActivationRecord::decode_canonical(&envelope.payload)?;
|
||||
activation.verify_for_expected(expected, idunn_anchor)?;
|
||||
Ok(activation)
|
||||
})
|
||||
.transpose()?;
|
||||
ensure!(
|
||||
projection_entry(
|
||||
&entries,
|
||||
IdunnProcessWriteLeaseRecord::TYPE,
|
||||
&key,
|
||||
)?
|
||||
.is_none(),
|
||||
"cannot demote an incarnation while its projected write lease remains"
|
||||
);
|
||||
|
||||
self.demote_projection(
|
||||
expected,
|
||||
provider_anchor,
|
||||
projected_activation.as_ref(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether the projection currently names an activation for this
|
||||
/// incarnation. Continuity asks so it can tell a projection that still
|
||||
/// describes a dead incarnation from one already demoted to Expected-only.
|
||||
|
|
@ -8213,7 +8278,7 @@ mod tests {
|
|||
IDUNN_PROCESS_WRITE_LEASE_SCHEMA, IdunnServiceIdentity,
|
||||
OdinRuntimeTopologyCorrelationPurpose, OdinTopologyAuthenticationContext,
|
||||
OdinTopologyIdentity, authenticate_odin_runtime_topology_correlation,
|
||||
enroll_service_identity_at, verify_runtime_authority,
|
||||
enroll_service_identity_at, open_service_identity_at, verify_runtime_authority,
|
||||
};
|
||||
|
||||
fn digest(byte: char) -> String {
|
||||
|
|
@ -10586,7 +10651,12 @@ mod tests {
|
|||
);
|
||||
assert!(!driver.projected_activation_is_present(&incumbent)?);
|
||||
let idempotent_bytes = fs::read(&driver.projection_store)?;
|
||||
driver.demote_to_expected_only(&incumbent, &provider_anchor, &activation, Some(&lease))?;
|
||||
driver.demote_to_expected_only(
|
||||
&incumbent,
|
||||
&provider_anchor,
|
||||
&activation,
|
||||
Some(&lease),
|
||||
)?;
|
||||
assert_eq!(fs::read(&driver.projection_store)?, idempotent_bytes);
|
||||
|
||||
// A record under the incumbent's key that is not the incumbent's is a
|
||||
|
|
@ -10650,6 +10720,104 @@ mod tests {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fenced_demotion_uses_the_current_verified_activation() -> Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let (expected, admitted_activation, warming, provider_anchor) =
|
||||
authenticated_warming(temp.path())?;
|
||||
let idunn = open_service_identity_at::<IdunnServiceIdentity>(&temp.path().join("idunn.cc"))?;
|
||||
let replacement = IdunnRuntimeActivationLaunch::issue(
|
||||
&expected,
|
||||
digest('a'),
|
||||
300,
|
||||
&idunn,
|
||||
)?
|
||||
.activation()
|
||||
.clone();
|
||||
assert_ne!(admitted_activation, replacement);
|
||||
|
||||
let driver = topology_driver(temp.path(), "fenced-demotion");
|
||||
driver.publish_expected(&expected, &provider_anchor)?;
|
||||
project_activation(&driver, &expected, &admitted_activation)?;
|
||||
let key = incarnation_key(&expected)?;
|
||||
upsert_record(
|
||||
&driver.projection_store,
|
||||
activation_envelope(&key, &replacement)?,
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
driver.demote_current_activation_to_expected_only(
|
||||
&expected,
|
||||
&provider_anchor,
|
||||
&idunn.trust_anchor()?,
|
||||
)?,
|
||||
expected.canonical_sha256()?
|
||||
);
|
||||
assert!(!driver.projected_activation_is_present(&expected)?);
|
||||
let after = SingleFileMessagePackBackingStore::new(&driver.projection_store)
|
||||
.pull_all_read_only_snapshot()?;
|
||||
assert!(after.iter().any(|entry| {
|
||||
entry.key == key && entry.r#type == IdunnExpectedIncarnationRecord::TYPE
|
||||
}));
|
||||
assert!(after.iter().all(|entry| {
|
||||
!(entry.key == key
|
||||
&& (entry.r#type == IdunnRuntimeActivationRecord::TYPE
|
||||
|| entry.r#type == IdunnProcessWriteLeaseRecord::TYPE))
|
||||
}));
|
||||
|
||||
// Expected-only demotion is idempotent when the activation is already
|
||||
// absent.
|
||||
let settled = fs::read(&driver.projection_store)?;
|
||||
driver.demote_current_activation_to_expected_only(
|
||||
&expected,
|
||||
&provider_anchor,
|
||||
&idunn.trust_anchor()?,
|
||||
)?;
|
||||
assert_eq!(fs::read(&driver.projection_store)?, settled);
|
||||
|
||||
// A record with an invalid Idunn signature is not adopted as current
|
||||
// authority, even after fencing.
|
||||
let mut forged = replacement;
|
||||
forged.runtime_instance_id = digest('b');
|
||||
upsert_record(
|
||||
&driver.projection_store,
|
||||
activation_envelope(&key, &forged)?,
|
||||
)?;
|
||||
let before_forgery_attempt = fs::read(&driver.projection_store)?;
|
||||
assert!(driver
|
||||
.demote_current_activation_to_expected_only(
|
||||
&expected,
|
||||
&provider_anchor,
|
||||
&idunn.trust_anchor()?,
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(fs::read(&driver.projection_store)?, before_forgery_attempt);
|
||||
|
||||
// A remaining lease means the target has not reached the demotion
|
||||
// boundary. The operation leaves every byte untouched.
|
||||
let incumbent_lease = lease(&expected, &admitted_activation, &warming);
|
||||
upsert_record(
|
||||
&driver.projection_store,
|
||||
CultCacheEnvelope {
|
||||
key: key.clone(),
|
||||
r#type: IdunnProcessWriteLeaseRecord::TYPE.into(),
|
||||
payload: incumbent_lease.canonical_bytes()?,
|
||||
stored_at: "2026-09-03T00:00:00Z".into(),
|
||||
schema_id: Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into()),
|
||||
},
|
||||
)?;
|
||||
let before_leased_attempt = fs::read(&driver.projection_store)?;
|
||||
assert!(driver
|
||||
.demote_current_activation_to_expected_only(
|
||||
&expected,
|
||||
&provider_anchor,
|
||||
&idunn.trust_anchor()?,
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(fs::read(&driver.projection_store)?, before_leased_attempt);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A replaced incarnation nothing owns is withdrawn whole; the one still
|
||||
/// admitted, and the target's anchor, are untouched.
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue