Implement verified OCI prepared-lower lifecycle

This commit is contained in:
ms2k 2026-07-15 15:22:02 +09:00
commit 5458460717
2 changed files with 4973 additions and 273 deletions

View file

@ -10,8 +10,8 @@ use std::time::{Duration, Instant};
use lazaret_core::codec::{self, CodecError};
use lazaret_core::identity::{PrepareOperationId, PreparedArtifactId};
use lazaret_core::records::{
ArtifactLease, ArtifactState, PrepareOperation, PreparedArtifact, StorageCatalog,
StorageCatalogMetadata,
ArtifactLease, ArtifactState, PrepareOperation, PrepareStageState, PreparedArtifact,
StorageCatalog, StorageCatalogMetadata,
};
use lazaret_linux::filesystem::{ExclusiveFileLock, try_exclusive_flock};
use redb::{
@ -106,6 +106,11 @@ impl Catalog {
///
/// Returns an error if the operation identity already exists or the transaction fails.
pub fn record_prepare(&self, operation: &PrepareOperation) -> Result<(), CatalogError> {
if operation.stage_state != PrepareStageState::Intent
|| !operation.has_valid_evidence_shape()
{
return Err(CatalogError::InvalidPrepareState);
}
self.write(|transaction| {
let key = operation_key(operation.operation_id);
let mut table = transaction
@ -120,6 +125,44 @@ impl Catalog {
})
}
/// Binds exact physical stage ownership to an already durable preparation intent.
///
/// # Errors
///
/// Returns an error for nonmatching identity, invalid phase/evidence, or transaction failure.
pub fn bind_prepare_stage(
&self,
expected_intent: &PrepareOperation,
owned_stage: &PrepareOperation,
) -> Result<(), CatalogError> {
if expected_intent.stage_state != PrepareStageState::Intent
|| owned_stage.stage_state != PrepareStageState::Owned
|| !expected_intent.has_valid_evidence_shape()
|| !owned_stage.has_valid_evidence_shape()
|| expected_intent.operation_id != owned_stage.operation_id
|| expected_intent.expected_artifact_id != owned_stage.expected_artifact_id
|| expected_intent.source_identity != owned_stage.source_identity
|| expected_intent.selected_manifest_digest != owned_stage.selected_manifest_digest
|| expected_intent.policy_id != owned_stage.policy_id
|| expected_intent.id_map_digest != owned_stage.id_map_digest
|| expected_intent.stage_path != owned_stage.stage_path
{
return Err(CatalogError::InvalidPrepareState);
}
self.write(|transaction| {
let key = operation_key(expected_intent.operation_id);
let mut table = transaction
.open_table(PREPARES)
.map_err(CatalogError::redb)?;
require_exact(&table, &key, expected_intent, CatalogRow::PrepareOperation)?;
let encoded = encode(owned_stage)?;
table
.insert(key.as_slice(), encoded.as_slice())
.map_err(CatalogError::redb)?;
Ok(())
})
}
/// Removes only an exactly matching preparation ownership row.
///
/// # Errors
@ -148,11 +191,19 @@ impl Catalog {
expected_operation: &PrepareOperation,
artifact: &PreparedArtifact,
) -> Result<(), CatalogError> {
if artifact.state != ArtifactState::Available {
if artifact.state != ArtifactState::Available
|| expected_operation.stage_state != PrepareStageState::Owned
|| !expected_operation.has_valid_evidence_shape()
{
return Err(CatalogError::InvalidArtifactState);
}
if artifact.prepared_artifact_id != expected_operation.expected_artifact_id
|| artifact.source_identity != expected_operation.source_identity
|| artifact.selected_manifest_digest != expected_operation.selected_manifest_digest
|| artifact.policy_id != expected_operation.policy_id
|| artifact.id_map_digest != expected_operation.id_map_digest
|| expected_operation.device != Some(artifact.device)
|| expected_operation.inode != Some(artifact.inode)
{
return Err(CatalogError::OwnershipMismatch {
row: CatalogRow::PrepareOperation,
@ -196,6 +247,82 @@ impl Catalog {
})
}
/// Atomically replaces an exact reclaiming generation and consumes its prepare ownership.
///
/// # Errors
///
/// Returns an error for mismatched old/new ownership, invalid state, leases, or transaction
/// failure.
pub fn publish_replacement(
&self,
expected_operation: &PrepareOperation,
expected_reclaiming: &PreparedArtifact,
replacement: &PreparedArtifact,
) -> Result<(), CatalogError> {
if expected_operation.stage_state != PrepareStageState::Owned
|| !expected_operation.has_valid_evidence_shape()
|| expected_reclaiming.state != ArtifactState::Reclaiming
|| replacement.state != ArtifactState::Available
|| replacement.prepared_artifact_id != expected_reclaiming.prepared_artifact_id
|| replacement.prepared_artifact_id != expected_operation.expected_artifact_id
|| replacement.source_identity != expected_operation.source_identity
|| replacement.selected_manifest_digest != expected_operation.selected_manifest_digest
|| replacement.policy_id != expected_operation.policy_id
|| replacement.id_map_digest != expected_operation.id_map_digest
|| expected_operation.device != Some(replacement.device)
|| expected_operation.inode != Some(replacement.inode)
|| replacement.source_identity != expected_reclaiming.source_identity
|| replacement.selected_manifest_digest != expected_reclaiming.selected_manifest_digest
|| replacement.policy_id != expected_reclaiming.policy_id
|| replacement.id_map_digest != expected_reclaiming.id_map_digest
|| replacement.path != expected_reclaiming.path
{
return Err(CatalogError::InvalidPrepareState);
}
self.write(|transaction| {
let prepare_key = operation_key(expected_operation.operation_id);
let artifact_key = artifact_key(replacement.prepared_artifact_id);
{
let prepares = transaction
.open_table(PREPARES)
.map_err(CatalogError::redb)?;
require_exact(
&prepares,
&prepare_key,
expected_operation,
CatalogRow::PrepareOperation,
)?;
let artifacts = transaction
.open_table(ARTIFACTS)
.map_err(CatalogError::redb)?;
require_exact(
&artifacts,
&artifact_key,
expected_reclaiming,
CatalogRow::PreparedArtifact,
)?;
}
if artifact_has_lease(transaction, replacement.prepared_artifact_id)? {
return Err(CatalogError::LeasesPresent);
}
let encoded = encode(replacement)?;
let mut artifacts = transaction
.open_table(ARTIFACTS)
.map_err(CatalogError::redb)?;
artifacts
.insert(artifact_key.as_slice(), encoded.as_slice())
.map_err(CatalogError::redb)?;
drop(artifacts);
let mut prepares = transaction
.open_table(PREPARES)
.map_err(CatalogError::redb)?;
prepares
.remove(prepare_key.as_slice())
.map_err(CatalogError::redb)?;
Ok(())
})
}
/// Inserts one exact Domain-generation lease for an available artifact.
///
/// # Errors
@ -570,6 +697,7 @@ pub enum CatalogError {
AlreadyExists { row: CatalogRow },
OwnershipMismatch { row: CatalogRow },
InvalidArtifactState,
InvalidPrepareState,
ArtifactUnavailable,
LeasesPresent,
}
@ -598,6 +726,9 @@ impl fmt::Display for CatalogError {
Self::InvalidArtifactState => {
formatter.write_str("artifact state is invalid for operation")
}
Self::InvalidPrepareState => {
formatter.write_str("prepare phase or physical evidence is invalid")
}
Self::ArtifactUnavailable => {
formatter.write_str("artifact is not available for leasing")
}

View file

@ -8,7 +8,9 @@ use lazaret_core::identity::{
DeviceId, DomainId, GenerationId, HostPath, IdMapDigest, InodeId, PolicyId, PrepareOperationId,
PreparedArtifactId, SelectedManifestDigest, SourceIdentity,
};
use lazaret_core::records::{ArtifactLease, ArtifactState, PrepareOperation, PreparedArtifact};
use lazaret_core::records::{
ArtifactLease, ArtifactState, PrepareOperation, PrepareStageState, PreparedArtifact,
};
use lazaret_linux::filesystem::try_exclusive_flock;
use lazaret_storage_catalog::{Catalog, CatalogError, CatalogRow};
use redb::{Database, TableDefinition};
@ -52,16 +54,29 @@ impl Fixture {
operation_id: PrepareOperationId::new([seed; 32]),
expected_artifact_id: artifact.prepared_artifact_id,
source_identity: artifact.source_identity.clone(),
selected_manifest_digest: artifact.selected_manifest_digest,
policy_id: artifact.policy_id,
id_map_digest: artifact.id_map_digest,
stage_path: HostPath::new(format!("stages/{seed}").into_bytes()),
device: DeviceId::new(u64::from(seed) + 200),
inode: InodeId::new(u64::from(seed) + 300),
stage_state: PrepareStageState::Owned,
device: Some(artifact.device),
inode: Some(artifact.inode),
}
}
fn publish(&self, seed: u8) -> PreparedArtifact {
let artifact = Self::artifact(seed);
let operation = Self::prepare(&artifact, seed);
self.catalog.record_prepare(&operation).unwrap();
let intent = PrepareOperation {
stage_state: PrepareStageState::Intent,
device: None,
inode: None,
..operation.clone()
};
self.catalog.record_prepare(&intent).unwrap();
self.catalog
.bind_prepare_stage(&intent, &operation)
.unwrap();
self.catalog.publish(&operation, &artifact).unwrap();
artifact
}
@ -97,10 +112,20 @@ fn publication_is_one_exact_transaction() {
let fixture = Fixture::new();
let artifact = Fixture::artifact(2);
let operation = Fixture::prepare(&artifact, 2);
fixture.catalog.record_prepare(&operation).unwrap();
let intent = PrepareOperation {
stage_state: PrepareStageState::Intent,
device: None,
inode: None,
..operation.clone()
};
fixture.catalog.record_prepare(&intent).unwrap();
fixture
.catalog
.bind_prepare_stage(&intent, &operation)
.unwrap();
let mut forged = operation.clone();
forged.inode = InodeId::new(999);
forged.inode = Some(InodeId::new(999));
assert!(matches!(
fixture.catalog.publish(&forged, &artifact),
Err(CatalogError::OwnershipMismatch {