Add the lock-owned transactional Domain Store
This commit is contained in:
parent
96b494a8bb
commit
e4d9cc2dc9
18 changed files with 1197 additions and 292 deletions
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -168,6 +168,9 @@ name = "lazaret-host-coordinator"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lazaret-core",
|
"lazaret-core",
|
||||||
|
"lazaret-linux",
|
||||||
|
"redb",
|
||||||
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -206,32 +206,6 @@ impl PidfsProcessIdentityV1 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
|
||||||
pub struct LegacyUnverifiedProcessIdentityV1 {
|
|
||||||
host_pid: HostPidLocator,
|
|
||||||
start_time_ticks: ProcessStartTimeTicks,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LegacyUnverifiedProcessIdentityV1 {
|
|
||||||
#[must_use]
|
|
||||||
pub const fn new(host_pid: HostPidLocator, start_time_ticks: ProcessStartTimeTicks) -> Self {
|
|
||||||
Self {
|
|
||||||
host_pid,
|
|
||||||
start_time_ticks,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub const fn host_pid(&self) -> HostPidLocator {
|
|
||||||
self.host_pid
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub const fn start_time_ticks(&self) -> ProcessStartTimeTicks {
|
|
||||||
self.start_time_ticks
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
|
||||||
pub struct NamespaceIdentity {
|
pub struct NamespaceIdentity {
|
||||||
pub device: DeviceId,
|
pub device: DeviceId,
|
||||||
|
|
@ -252,10 +226,10 @@ mod tests {
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
BootId, ContainerId, CoordinatorId, DeviceId, DomainId, GenerationId, GuestProcessId,
|
BootId, ContainerId, CoordinatorId, DeviceId, DomainId, GenerationId, GuestProcessId,
|
||||||
GuestProcessStartTimeTicks, HostPidLocator, LegacyUnverifiedProcessIdentityV1,
|
GuestProcessStartTimeTicks, HostPidLocator, MountIdentity, NamespaceIdentity,
|
||||||
MountIdentity, NamespaceIdentity, NetworkSidecarId, PidfsDeviceId, PidfsInodeId,
|
NetworkSidecarId, PidfsDeviceId, PidfsInodeId, PidfsProcessAuthorityV1,
|
||||||
PidfsProcessAuthorityV1, PidfsProcessIdentityV1, PreparedArtifactId, ProcessStartTimeTicks,
|
PidfsProcessIdentityV1, PreparedArtifactId, ProcessStartTimeTicks, RequestId, SandboxId,
|
||||||
RequestId, SandboxId, SessionId,
|
SessionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -273,7 +247,6 @@ mod tests {
|
||||||
TypeId::of::<BootId>(),
|
TypeId::of::<BootId>(),
|
||||||
TypeId::of::<PidfsProcessAuthorityV1>(),
|
TypeId::of::<PidfsProcessAuthorityV1>(),
|
||||||
TypeId::of::<PidfsProcessIdentityV1>(),
|
TypeId::of::<PidfsProcessIdentityV1>(),
|
||||||
TypeId::of::<LegacyUnverifiedProcessIdentityV1>(),
|
|
||||||
TypeId::of::<HostPidLocator>(),
|
TypeId::of::<HostPidLocator>(),
|
||||||
TypeId::of::<ProcessStartTimeTicks>(),
|
TypeId::of::<ProcessStartTimeTicks>(),
|
||||||
TypeId::of::<GuestProcessId>(),
|
TypeId::of::<GuestProcessId>(),
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,10 @@ use crate::identity::{
|
||||||
BundleDigest, CapabilityId, CertificateDigest, ClientId, ConfigurationFingerprint, ContainerId,
|
BundleDigest, CapabilityId, CertificateDigest, ClientId, ConfigurationFingerprint, ContainerId,
|
||||||
CoordinatorId, DerivedPolicyDigest, DeviceId, DomainId, FileDescriptor, GenerationId,
|
CoordinatorId, DerivedPolicyDigest, DeviceId, DomainId, FileDescriptor, GenerationId,
|
||||||
GuestProcessGroupId, GuestProcessId, GuestProcessStartTimeTicks, HostPath, IdMapDigest,
|
GuestProcessGroupId, GuestProcessId, GuestProcessStartTimeTicks, HostPath, IdMapDigest,
|
||||||
InodeId, LegacyUnverifiedProcessIdentityV1, MountIdentity, NamespaceIdentity, NetworkSidecarId,
|
InodeId, MountIdentity, NamespaceIdentity, NetworkSidecarId, PidfsProcessAuthorityV1,
|
||||||
PidfsProcessAuthorityV1, PidfsProcessIdentityV1, PolicyId, PrepareOperationId,
|
PidfsProcessIdentityV1, PolicyId, PrepareOperationId, PreparedArtifactId, ProtocolCredentialId,
|
||||||
PreparedArtifactId, ProtocolCredentialId, ReadinessFingerprint, RequestDigest, RequestId,
|
ReadinessFingerprint, RequestDigest, RequestId, SandboxId, SelectedManifestDigest,
|
||||||
SandboxId, SelectedManifestDigest, SessionAnchorId, SessionId, SourceIdentity, StreamDigest,
|
SessionAnchorId, SessionId, SourceIdentity, StreamDigest, TrustManifestDigest,
|
||||||
TrustManifestDigest,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
mod configuration;
|
mod configuration;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
use super::{BTreeMap, Deserialize, HostPath, Serialize};
|
use super::{BTreeMap, Deserialize, HostPath, Serialize};
|
||||||
|
|
||||||
pub const DOMAIN_STORE_SCHEMA_ID: &str = "lazaret-domain-store-v2";
|
pub const DOMAIN_STORE_SCHEMA_ID: &str = "lazaret-domain-store-v1";
|
||||||
pub const LEGACY_DOMAIN_STORE_SCHEMA_ID: &str = "lazaret-domain-store-v1";
|
|
||||||
pub const STORAGE_CATALOG_SCHEMA_ID: &str = "lazaret-storage-catalog-v1";
|
pub const STORAGE_CATALOG_SCHEMA_ID: &str = "lazaret-storage-catalog-v1";
|
||||||
pub const DOMAIN_STORE_SCHEMA_VERSION: u32 = 2;
|
pub const DOMAIN_STORE_SCHEMA_VERSION: u32 = 1;
|
||||||
pub const LEGACY_DOMAIN_STORE_SCHEMA_VERSION: u32 = 1;
|
|
||||||
pub const STORAGE_CATALOG_SCHEMA_VERSION: u32 = 1;
|
pub const STORAGE_CATALOG_SCHEMA_VERSION: u32 = 1;
|
||||||
pub const TRUSTED_HOST_CONFIGURATION_SCHEMA_ID: &str = "lazaret-trusted-host-config-v1";
|
pub const TRUSTED_HOST_CONFIGURATION_SCHEMA_ID: &str = "lazaret-trusted-host-config-v1";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,10 @@ use super::{
|
||||||
BundleDigest, ClientId, ConfigurationFingerprint, ContainerId, CoordinatorId,
|
BundleDigest, ClientId, ConfigurationFingerprint, ContainerId, CoordinatorId,
|
||||||
DOMAIN_STORE_SCHEMA_ID, DOMAIN_STORE_SCHEMA_VERSION, DerivedPolicyDigest, Deserialize,
|
DOMAIN_STORE_SCHEMA_ID, DOMAIN_STORE_SCHEMA_VERSION, DerivedPolicyDigest, Deserialize,
|
||||||
DeviceId, DomainId, FileDescriptor, GenerationId, GuestProcessGroupId, GuestProcessId,
|
DeviceId, DomainId, FileDescriptor, GenerationId, GuestProcessGroupId, GuestProcessId,
|
||||||
GuestProcessStartTimeTicks, HostPath, InodeId, LEGACY_DOMAIN_STORE_SCHEMA_ID,
|
GuestProcessStartTimeTicks, HostPath, InodeId, MountIdentity, NamespaceIdentity,
|
||||||
LEGACY_DOMAIN_STORE_SCHEMA_VERSION, LegacyUnverifiedProcessIdentityV1, MountIdentity,
|
NetworkReadinessEvidence, NetworkSidecarId, PidfsProcessIdentityV1, PreparedArtifactId,
|
||||||
NamespaceIdentity, NetworkReadinessEvidence, NetworkSidecarId, PidfsProcessIdentityV1,
|
ReadinessFingerprint, RecordInvariantError, RecordTransitionError, RequestId,
|
||||||
PreparedArtifactId, ReadinessFingerprint, RecordInvariantError, RecordTransitionError,
|
STORAGE_CATALOG_SCHEMA_ID, STORAGE_CATALOG_SCHEMA_VERSION, SandboxId, Serialize,
|
||||||
RequestId, STORAGE_CATALOG_SCHEMA_ID, STORAGE_CATALOG_SCHEMA_VERSION, SandboxId, Serialize,
|
|
||||||
SessionAnchorId, SessionId, SourceIdentity, fmt,
|
SessionAnchorId, SessionId, SourceIdentity, fmt,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -18,21 +17,13 @@ pub struct StoreMetadata {
|
||||||
|
|
||||||
impl StoreMetadata {
|
impl StoreMetadata {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn domain_store_v2() -> Self {
|
pub fn domain_store_v1() -> Self {
|
||||||
Self {
|
Self {
|
||||||
schema_id: DOMAIN_STORE_SCHEMA_ID.into(),
|
schema_id: DOMAIN_STORE_SCHEMA_ID.into(),
|
||||||
schema_version: DOMAIN_STORE_SCHEMA_VERSION,
|
schema_version: DOMAIN_STORE_SCHEMA_VERSION,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn legacy_domain_store_v1() -> Self {
|
|
||||||
Self {
|
|
||||||
schema_id: LEGACY_DOMAIN_STORE_SCHEMA_ID.into(),
|
|
||||||
schema_version: LEGACY_DOMAIN_STORE_SCHEMA_VERSION,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn storage_catalog_v1() -> Self {
|
pub fn storage_catalog_v1() -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
|
@ -56,11 +47,6 @@ impl StoreMetadata {
|
||||||
&& self.schema_version == DOMAIN_STORE_SCHEMA_VERSION
|
&& self.schema_version == DOMAIN_STORE_SCHEMA_VERSION
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_legacy_domain_store(&self) -> bool {
|
|
||||||
self.schema_id == LEGACY_DOMAIN_STORE_SCHEMA_ID
|
|
||||||
&& self.schema_version == LEGACY_DOMAIN_STORE_SCHEMA_VERSION
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_storage_catalog(&self) -> bool {
|
fn is_storage_catalog(&self) -> bool {
|
||||||
self.schema_id == STORAGE_CATALOG_SCHEMA_ID
|
self.schema_id == STORAGE_CATALOG_SCHEMA_ID
|
||||||
&& self.schema_version == STORAGE_CATALOG_SCHEMA_VERSION
|
&& self.schema_version == STORAGE_CATALOG_SCHEMA_VERSION
|
||||||
|
|
@ -83,10 +69,7 @@ impl<'de> Deserialize<'de> for StoreMetadata {
|
||||||
schema_id: fields.schema_id,
|
schema_id: fields.schema_id,
|
||||||
schema_version: fields.schema_version,
|
schema_version: fields.schema_version,
|
||||||
};
|
};
|
||||||
if metadata.is_domain_store()
|
if metadata.is_domain_store() || metadata.is_storage_catalog() {
|
||||||
|| metadata.is_legacy_domain_store()
|
|
||||||
|| metadata.is_storage_catalog()
|
|
||||||
{
|
|
||||||
Ok(metadata)
|
Ok(metadata)
|
||||||
} else {
|
} else {
|
||||||
Err(serde::de::Error::custom(
|
Err(serde::de::Error::custom(
|
||||||
|
|
@ -100,24 +83,9 @@ impl<'de> Deserialize<'de> for StoreMetadata {
|
||||||
pub struct DomainStoreMetadata(StoreMetadata);
|
pub struct DomainStoreMetadata(StoreMetadata);
|
||||||
|
|
||||||
impl DomainStoreMetadata {
|
impl DomainStoreMetadata {
|
||||||
#[must_use]
|
|
||||||
pub fn v2() -> Self {
|
|
||||||
Self(StoreMetadata::domain_store_v2())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub const fn as_metadata(&self) -> &StoreMetadata {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
|
||||||
pub struct LegacyDomainStoreMetadata(StoreMetadata);
|
|
||||||
|
|
||||||
impl LegacyDomainStoreMetadata {
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn v1() -> Self {
|
pub fn v1() -> Self {
|
||||||
Self(StoreMetadata::legacy_domain_store_v1())
|
Self(StoreMetadata::domain_store_v1())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
|
|
@ -126,22 +94,6 @@ impl LegacyDomainStoreMetadata {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for LegacyDomainStoreMetadata {
|
|
||||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
||||||
where
|
|
||||||
D: serde::Deserializer<'de>,
|
|
||||||
{
|
|
||||||
let metadata = StoreMetadata::deserialize(deserializer)?;
|
|
||||||
if metadata.is_legacy_domain_store() {
|
|
||||||
Ok(Self(metadata))
|
|
||||||
} else {
|
|
||||||
Err(serde::de::Error::custom(
|
|
||||||
"expected legacy Domain Store v1 metadata",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for DomainStoreMetadata {
|
impl<'de> Deserialize<'de> for DomainStoreMetadata {
|
||||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
where
|
where
|
||||||
|
|
@ -235,42 +187,17 @@ pub enum ParticipantState {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||||
pub enum HostProcessIdentity {
|
pub struct HostProcessIdentity(PidfsProcessIdentityV1);
|
||||||
PidfsV1(PidfsProcessIdentityV1),
|
|
||||||
LegacyUnverifiedV1(LegacyUnverifiedProcessIdentityV1),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub struct LegacyProcessIdentityRefusal;
|
|
||||||
|
|
||||||
impl fmt::Display for LegacyProcessIdentityRefusal {
|
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
formatter.write_str("legacy PID/start-only process identity has no positive authority")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for LegacyProcessIdentityRefusal {}
|
|
||||||
|
|
||||||
impl HostProcessIdentity {
|
impl HostProcessIdentity {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn pidfs_v1(identity: PidfsProcessIdentityV1) -> Self {
|
pub const fn pidfs_v1(identity: PidfsProcessIdentityV1) -> Self {
|
||||||
Self::PidfsV1(identity)
|
Self(identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn legacy_unverified_v1(identity: LegacyUnverifiedProcessIdentityV1) -> Self {
|
pub const fn pidfs(&self) -> &PidfsProcessIdentityV1 {
|
||||||
Self::LegacyUnverifiedV1(identity)
|
&self.0
|
||||||
}
|
|
||||||
|
|
||||||
/// # Errors
|
|
||||||
/// Legacy PID/start-only data has no positive authority.
|
|
||||||
pub const fn verified_pidfs(
|
|
||||||
&self,
|
|
||||||
) -> Result<&PidfsProcessIdentityV1, LegacyProcessIdentityRefusal> {
|
|
||||||
match self {
|
|
||||||
Self::PidfsV1(identity) => Ok(identity),
|
|
||||||
Self::LegacyUnverifiedV1(_) => Err(LegacyProcessIdentityRefusal),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -297,11 +224,6 @@ impl PersistedHostProcessOwnership {
|
||||||
Self::Committed(HostProcessIdentity::pidfs_v1(identity))
|
Self::Committed(HostProcessIdentity::pidfs_v1(identity))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub const fn legacy_unverified(identity: LegacyUnverifiedProcessIdentityV1) -> Self {
|
|
||||||
Self::Committed(HostProcessIdentity::legacy_unverified_v1(identity))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn is_staged(&self) -> bool {
|
pub const fn is_staged(&self) -> bool {
|
||||||
matches!(self, Self::Staged(_))
|
matches!(self, Self::Staged(_))
|
||||||
|
|
@ -314,7 +236,7 @@ impl PersistedHostProcessOwnership {
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn is_current_committed(&self) -> bool {
|
pub const fn is_current_committed(&self) -> bool {
|
||||||
matches!(self, Self::Committed(HostProcessIdentity::PidfsV1(_)))
|
matches!(self, Self::Committed(_))
|
||||||
}
|
}
|
||||||
|
|
||||||
const fn is_initial_acquisition(&self) -> bool {
|
const fn is_initial_acquisition(&self) -> bool {
|
||||||
|
|
@ -326,29 +248,22 @@ impl PersistedHostProcessOwnership {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Unassigned or legacy-unverified ownership has no positive authority.
|
/// Unassigned ownership has no positive authority.
|
||||||
pub fn authoritative_pidfs(&self) -> Result<&PidfsProcessIdentityV1, ProcessAuthorityRefusal> {
|
pub fn authoritative_pidfs(&self) -> Result<&PidfsProcessIdentityV1, ProcessAuthorityRefusal> {
|
||||||
match self {
|
match self {
|
||||||
Self::Unassigned => Err(ProcessAuthorityRefusal::Unassigned),
|
Self::Unassigned => Err(ProcessAuthorityRefusal::Unassigned),
|
||||||
Self::Staged(identity) | Self::Committed(HostProcessIdentity::PidfsV1(identity)) => {
|
Self::Staged(identity) => Ok(identity),
|
||||||
Ok(identity)
|
Self::Committed(identity) => Ok(identity.pidfs()),
|
||||||
}
|
|
||||||
Self::Committed(HostProcessIdentity::LegacyUnverifiedV1(_)) => {
|
|
||||||
Err(ProcessAuthorityRefusal::LegacyUnverified)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Unassigned, staged, or legacy-unverified ownership is not serviceable.
|
/// Unassigned or staged ownership is not serviceable.
|
||||||
pub fn serviceable_pidfs(&self) -> Result<&PidfsProcessIdentityV1, ProcessOwnershipRefusal> {
|
pub fn serviceable_pidfs(&self) -> Result<&PidfsProcessIdentityV1, ProcessOwnershipRefusal> {
|
||||||
match self {
|
match self {
|
||||||
Self::Unassigned => Err(ProcessOwnershipRefusal::Unassigned),
|
Self::Unassigned => Err(ProcessOwnershipRefusal::Unassigned),
|
||||||
Self::Staged(_) => Err(ProcessOwnershipRefusal::Staged),
|
Self::Staged(_) => Err(ProcessOwnershipRefusal::Staged),
|
||||||
Self::Committed(HostProcessIdentity::PidfsV1(identity)) => Ok(identity),
|
Self::Committed(identity) => Ok(identity.pidfs()),
|
||||||
Self::Committed(HostProcessIdentity::LegacyUnverifiedV1(_)) => {
|
|
||||||
Err(ProcessOwnershipRefusal::LegacyUnverified)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -357,7 +272,6 @@ impl PersistedHostProcessOwnership {
|
||||||
pub enum ProcessOwnershipRefusal {
|
pub enum ProcessOwnershipRefusal {
|
||||||
Unassigned,
|
Unassigned,
|
||||||
Staged,
|
Staged,
|
||||||
LegacyUnverified,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for ProcessOwnershipRefusal {
|
impl fmt::Display for ProcessOwnershipRefusal {
|
||||||
|
|
@ -365,8 +279,6 @@ impl fmt::Display for ProcessOwnershipRefusal {
|
||||||
match self {
|
match self {
|
||||||
Self::Unassigned => formatter.write_str("process ownership is unassigned"),
|
Self::Unassigned => formatter.write_str("process ownership is unassigned"),
|
||||||
Self::Staged => formatter.write_str("process ownership is staged, not committed"),
|
Self::Staged => formatter.write_str("process ownership is staged, not committed"),
|
||||||
Self::LegacyUnverified => formatter
|
|
||||||
.write_str("legacy PID/start-only process ownership has no positive authority"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -376,15 +288,12 @@ impl std::error::Error for ProcessOwnershipRefusal {}
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum ProcessAuthorityRefusal {
|
pub enum ProcessAuthorityRefusal {
|
||||||
Unassigned,
|
Unassigned,
|
||||||
LegacyUnverified,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for ProcessAuthorityRefusal {
|
impl fmt::Display for ProcessAuthorityRefusal {
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::Unassigned => formatter.write_str("process ownership is unassigned"),
|
Self::Unassigned => formatter.write_str("process ownership is unassigned"),
|
||||||
Self::LegacyUnverified => formatter
|
|
||||||
.write_str("legacy PID/start-only process ownership has no positive authority"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1507,8 +1416,8 @@ fn validate_process_progress(
|
||||||
) if old == next => Ok(()),
|
) if old == next => Ok(()),
|
||||||
(
|
(
|
||||||
PersistedHostProcessOwnership::Staged(old),
|
PersistedHostProcessOwnership::Staged(old),
|
||||||
PersistedHostProcessOwnership::Committed(HostProcessIdentity::PidfsV1(next)),
|
PersistedHostProcessOwnership::Committed(next),
|
||||||
) if old == next => Ok(()),
|
) if old == next.pidfs() => Ok(()),
|
||||||
(
|
(
|
||||||
PersistedHostProcessOwnership::Committed(old),
|
PersistedHostProcessOwnership::Committed(old),
|
||||||
PersistedHostProcessOwnership::Committed(next),
|
PersistedHostProcessOwnership::Committed(next),
|
||||||
|
|
@ -1525,7 +1434,7 @@ fn validate_existing_process_progress(
|
||||||
(old, next),
|
(old, next),
|
||||||
(
|
(
|
||||||
PersistedHostProcessOwnership::Unassigned,
|
PersistedHostProcessOwnership::Unassigned,
|
||||||
PersistedHostProcessOwnership::Committed(HostProcessIdentity::PidfsV1(_))
|
PersistedHostProcessOwnership::Committed(_)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,20 @@ use super::{
|
||||||
DerivedNetworkPolicy, DeveloperDecision, DeveloperDecisionKind, DnsObservation, Domain,
|
DerivedNetworkPolicy, DeveloperDecision, DeveloperDecisionKind, DnsObservation, Domain,
|
||||||
DomainStore, EnvironmentReference, EscalationOutcome, Generation, GenerationIdentity,
|
DomainStore, EnvironmentReference, EscalationOutcome, Generation, GenerationIdentity,
|
||||||
GitRoutingOwnership, GuestProcessIdentity, HelperOwnership, HostProcessIdentity,
|
GitRoutingOwnership, GuestProcessIdentity, HelperOwnership, HostProcessIdentity,
|
||||||
HostProcessOwnershipSlot, LegacyUnverifiedProcessIdentityV1, Mailbox, MailboxCancellation,
|
HostProcessOwnershipSlot, Mailbox, MailboxCancellation, MailboxRequest, MailboxResult,
|
||||||
MailboxRequest, MailboxResult, MailboxResultAck, MailboxResultChunk, MountIdentity,
|
MailboxResultAck, MailboxResultChunk, MountIdentity, NamespaceIdentity, NetworkObservation,
|
||||||
NamespaceIdentity, NetworkObservation, NetworkOwnership, NetworkReachabilityProbe,
|
NetworkOwnership, NetworkReachabilityProbe, NetworkReadinessAttestation,
|
||||||
NetworkReadinessAttestation, NetworkReadinessEvidence, NetworkReadinessIdentity, Participant,
|
NetworkReadinessEvidence, NetworkReadinessIdentity, Participant, ParticipantOwnership,
|
||||||
ParticipantOwnership, ParticipantState, PermittedDestinationObservation,
|
ParticipantState, PermittedDestinationObservation, PersistedHostProcessOwnership,
|
||||||
PersistedHostProcessOwnership, PidfsProcessAuthorityV1, PidfsProcessIdentityV1, PolicyAction,
|
PidfsProcessAuthorityV1, PidfsProcessIdentityV1, PolicyAction, PolicyDelivery, PrefixRule,
|
||||||
PolicyDelivery, PrefixRule, PrepareOperation, PreparedArtifact, PreparedLowerOwnership,
|
PrepareOperation, PreparedArtifact, PreparedLowerOwnership, ProcessOwnershipCommitAck,
|
||||||
ProcessOwnershipCommitAck, ProcessOwnershipMessageVersion, ProtocolCredential,
|
ProcessOwnershipMessageVersion, ProtocolCredential, ProtocolCredentialRole,
|
||||||
ProtocolCredentialRole, ProtocolPublicationIdentity, PtyIdentity, PublicationOwnership,
|
ProtocolPublicationIdentity, PtyIdentity, PublicationOwnership, ResolvedAutoApprovalRule,
|
||||||
ResolvedAutoApprovalRule, ResolvedVolume, Resolver, ResultStream, SandboxCredentialBundle,
|
ResolvedVolume, Resolver, ResultStream, SandboxCredentialBundle, SandboxOwnership, Session,
|
||||||
SandboxOwnership, Session, SessionBinding, SessionClose, SessionExit, SessionExitOutcome,
|
SessionBinding, SessionClose, SessionExit, SessionExitOutcome, SessionRequestCapability,
|
||||||
SessionRequestCapability, SessionTerminal, SessionTerminalEvent, SessionTerminalMessage,
|
SessionTerminal, SessionTerminalEvent, SessionTerminalMessage, SessionTerminalState,
|
||||||
SessionTerminalState, SignedProtocolRecord, StagedProcessOwnership, StateAnchorOwnership,
|
SignedProtocolRecord, StagedProcessOwnership, StateAnchorOwnership, StorageCatalog,
|
||||||
StorageCatalog, StoreMetadata, TerminalInput, TerminalOutput, TerminalResize, TerminalSignal,
|
StoreMetadata, TerminalInput, TerminalOutput, TerminalResize, TerminalSignal,
|
||||||
TerminalSignalKind, TlsCredential, TlsRole, TrustManifest, TrustedHostConfigurationV1,
|
TerminalSignalKind, TlsCredential, TlsRole, TrustManifest, TrustedHostConfigurationV1,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -55,7 +55,6 @@ schema_types!(
|
||||||
"PtyIdentity" => PtyIdentity,
|
"PtyIdentity" => PtyIdentity,
|
||||||
"PidfsProcessAuthorityV1" => PidfsProcessAuthorityV1,
|
"PidfsProcessAuthorityV1" => PidfsProcessAuthorityV1,
|
||||||
"PidfsProcessIdentityV1" => PidfsProcessIdentityV1,
|
"PidfsProcessIdentityV1" => PidfsProcessIdentityV1,
|
||||||
"LegacyUnverifiedProcessIdentityV1" => LegacyUnverifiedProcessIdentityV1,
|
|
||||||
"HostProcessIdentity" => HostProcessIdentity,
|
"HostProcessIdentity" => HostProcessIdentity,
|
||||||
"PersistedHostProcessOwnership" => PersistedHostProcessOwnership,
|
"PersistedHostProcessOwnership" => PersistedHostProcessOwnership,
|
||||||
"HostProcessOwnershipSlot" => HostProcessOwnershipSlot,
|
"HostProcessOwnershipSlot" => HostProcessOwnershipSlot,
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,11 @@ use lazaret_core::identity::{
|
||||||
BootId, BundleDigest, CapabilityId, CertificateDigest, ClientId, ConfigurationFingerprint,
|
BootId, BundleDigest, CapabilityId, CertificateDigest, ClientId, ConfigurationFingerprint,
|
||||||
ContainerId, CoordinatorId, DerivedPolicyDigest, DeviceId, DomainId, FileDescriptor,
|
ContainerId, CoordinatorId, DerivedPolicyDigest, DeviceId, DomainId, FileDescriptor,
|
||||||
GenerationId, GuestProcessGroupId, GuestProcessId, GuestProcessStartTimeTicks, HostPath,
|
GenerationId, GuestProcessGroupId, GuestProcessId, GuestProcessStartTimeTicks, HostPath,
|
||||||
HostPidLocator, IdMapDigest, InodeId, LegacyUnverifiedProcessIdentityV1, MountId,
|
HostPidLocator, IdMapDigest, InodeId, MountId, MountIdentity, NamespaceIdentity,
|
||||||
MountIdentity, NamespaceIdentity, NetworkSidecarId, PidfsDeviceId, PidfsInodeId,
|
NetworkSidecarId, PidfsDeviceId, PidfsInodeId, PidfsProcessAuthorityV1, PidfsProcessIdentityV1,
|
||||||
PidfsProcessAuthorityV1, PidfsProcessIdentityV1, PolicyId, PrepareOperationId,
|
PolicyId, PrepareOperationId, PreparedArtifactId, ProcessStartTimeTicks, ProtocolCredentialId,
|
||||||
PreparedArtifactId, ProcessStartTimeTicks, ProtocolCredentialId, ReadinessFingerprint,
|
ReadinessFingerprint, RequestDigest, RequestId, SandboxId, SelectedManifestDigest,
|
||||||
RequestDigest, RequestId, SandboxId, SelectedManifestDigest, SessionAnchorId, SessionId,
|
SessionAnchorId, SessionId, SourceIdentity, StreamDigest, TrustManifestDigest,
|
||||||
SourceIdentity, StreamDigest, TrustManifestDigest,
|
|
||||||
};
|
};
|
||||||
use lazaret_core::records::*;
|
use lazaret_core::records::*;
|
||||||
use serde::de::DeserializeOwned;
|
use serde::de::DeserializeOwned;
|
||||||
|
|
|
||||||
|
|
@ -40,22 +40,8 @@ fn store_identity_and_versions_dispatch_before_rows() {
|
||||||
decode::<DomainStoreMetadata>(&encode(&StoreMetadata::storage_catalog_v1()).unwrap()),
|
decode::<DomainStoreMetadata>(&encode(&StoreMetadata::storage_catalog_v1()).unwrap()),
|
||||||
Err(CodecError::MalformedPayload(_))
|
Err(CodecError::MalformedPayload(_))
|
||||||
));
|
));
|
||||||
for domain_metadata in [
|
|
||||||
StoreMetadata::domain_store_v2(),
|
|
||||||
StoreMetadata::legacy_domain_store_v1(),
|
|
||||||
] {
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
decode::<StorageCatalogMetadata>(&encode(&domain_metadata).unwrap()),
|
decode::<StorageCatalogMetadata>(&encode(&StoreMetadata::domain_store_v1()).unwrap()),
|
||||||
Err(CodecError::MalformedPayload(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let legacy = StoreMetadata::legacy_domain_store_v1();
|
|
||||||
assert_eq!(
|
|
||||||
decode::<LegacyDomainStoreMetadata>(&encode(&legacy).unwrap()).unwrap(),
|
|
||||||
LegacyDomainStoreMetadata::v1()
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
decode::<DomainStoreMetadata>(&encode(&legacy).unwrap()),
|
|
||||||
Err(CodecError::MalformedPayload(_))
|
Err(CodecError::MalformedPayload(_))
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
@ -79,7 +65,7 @@ fn store_identity_and_versions_dispatch_before_rows() {
|
||||||
));
|
));
|
||||||
|
|
||||||
let wrong_catalog = postcard::to_stdvec(&RawStorageCatalog {
|
let wrong_catalog = postcard::to_stdvec(&RawStorageCatalog {
|
||||||
metadata: StoreMetadata::domain_store_v2(),
|
metadata: StoreMetadata::domain_store_v1(),
|
||||||
prepared_artifacts: vec![],
|
prepared_artifacts: vec![],
|
||||||
artifact_leases: vec![],
|
artifact_leases: vec![],
|
||||||
prepare_operations: vec![],
|
prepare_operations: vec![],
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
|
||||||
outbox_path: HostPath::new(b"outbox".to_vec()),
|
outbox_path: HostPath::new(b"outbox".to_vec()),
|
||||||
state: ParticipantState::Committed,
|
state: ParticipantState::Committed,
|
||||||
};
|
};
|
||||||
let metadata = StoreMetadata::domain_store_v2();
|
let metadata = StoreMetadata::domain_store_v1();
|
||||||
let tls_credential = TlsCredential::session_coordinator(
|
let tls_credential = TlsCredential::session_coordinator(
|
||||||
generation_identity(),
|
generation_identity(),
|
||||||
coordinator_id(),
|
coordinator_id(),
|
||||||
|
|
@ -110,13 +110,6 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
|
||||||
);
|
);
|
||||||
verify_shape(pidfs_identity.authority(), &mut seen);
|
verify_shape(pidfs_identity.authority(), &mut seen);
|
||||||
verify_shape(pidfs_identity, &mut seen);
|
verify_shape(pidfs_identity, &mut seen);
|
||||||
verify_shape(
|
|
||||||
LegacyUnverifiedProcessIdentityV1::new(
|
|
||||||
HostPidLocator::new(48),
|
|
||||||
ProcessStartTimeTicks::new(480),
|
|
||||||
),
|
|
||||||
&mut seen,
|
|
||||||
);
|
|
||||||
verify_shape(HostProcessIdentity::pidfs_v1(pidfs_identity), &mut seen);
|
verify_shape(HostProcessIdentity::pidfs_v1(pidfs_identity), &mut seen);
|
||||||
verify_shape(
|
verify_shape(
|
||||||
PersistedHostProcessOwnership::staged(pidfs_identity),
|
PersistedHostProcessOwnership::staged(pidfs_identity),
|
||||||
|
|
@ -442,7 +435,7 @@ fn every_schema_shape_round_trips_and_rejects_malformed_or_unknown_versions() {
|
||||||
);
|
);
|
||||||
verify_shape(
|
verify_shape(
|
||||||
DomainStore {
|
DomainStore {
|
||||||
metadata: DomainStoreMetadata::v2(),
|
metadata: DomainStoreMetadata::v1(),
|
||||||
domain,
|
domain,
|
||||||
generations: vec![generation],
|
generations: vec![generation],
|
||||||
participants: vec![participant],
|
participants: vec![participant],
|
||||||
|
|
@ -729,7 +722,7 @@ fn trailing_and_nonminimal_postcard_encodings_are_rejected() {
|
||||||
Err(CodecError::NonMinimalPayload)
|
Err(CodecError::NonMinimalPayload)
|
||||||
));
|
));
|
||||||
|
|
||||||
let mut overlong_store_id = encode(&StoreMetadata::domain_store_v2()).unwrap();
|
let mut overlong_store_id = encode(&StoreMetadata::domain_store_v1()).unwrap();
|
||||||
overlong_store_id[0] |= 0x80;
|
overlong_store_id[0] |= 0x80;
|
||||||
overlong_store_id.insert(1, 0);
|
overlong_store_id.insert(1, 0);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|
|
||||||
|
|
@ -136,21 +136,6 @@ fn newly_recorded_existing_processes_do_not_bypass_child_staging() {
|
||||||
Ok(()),
|
Ok(()),
|
||||||
"{edge:?}"
|
"{edge:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
let legacy = participant_edge_snapshot(
|
|
||||||
edge,
|
|
||||||
PersistedHostProcessOwnership::legacy_unverified(
|
|
||||||
LegacyUnverifiedProcessIdentityV1::new(
|
|
||||||
current.host_pid(),
|
|
||||||
current.start_time_ticks(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
unassigned.validate_transition_to(&legacy),
|
|
||||||
Err(RecordTransitionError::ProcessStageSkipped),
|
|
||||||
"{edge:?}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let acquired_edges = [
|
let acquired_edges = [
|
||||||
|
|
@ -212,21 +197,6 @@ fn newly_recorded_existing_processes_do_not_bypass_child_staging() {
|
||||||
terminal_unassigned.validate_transition_to(&terminal_existing_hc),
|
terminal_unassigned.validate_transition_to(&terminal_existing_hc),
|
||||||
Ok(())
|
Ok(())
|
||||||
);
|
);
|
||||||
let terminal_legacy_hc = terminal_snapshot(
|
|
||||||
SessionTerminalState::Intent,
|
|
||||||
PersistedHostProcessOwnership::legacy_unverified(LegacyUnverifiedProcessIdentityV1::new(
|
|
||||||
HostPidLocator::new(101),
|
|
||||||
ProcessStartTimeTicks::new(1_010),
|
|
||||||
)),
|
|
||||||
PersistedHostProcessOwnership::unassigned(),
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
terminal_unassigned.validate_transition_to(&terminal_legacy_hc),
|
|
||||||
Err(RecordTransitionError::ProcessStageSkipped)
|
|
||||||
);
|
|
||||||
|
|
||||||
let terminal_exec_staged = terminal_snapshot(
|
let terminal_exec_staged = terminal_snapshot(
|
||||||
SessionTerminalState::Intent,
|
SessionTerminalState::Intent,
|
||||||
owned_process(101),
|
owned_process(101),
|
||||||
|
|
|
||||||
|
|
@ -77,22 +77,7 @@ fn process_slot_separates_cleanup_authority_from_serviceability() {
|
||||||
Err(ProcessAuthorityRefusal::Unassigned)
|
Err(ProcessAuthorityRefusal::Unassigned)
|
||||||
);
|
);
|
||||||
|
|
||||||
let legacy = LegacyUnverifiedProcessIdentityV1::new(
|
assert_eq!(HostProcessIdentity::pidfs_v1(current).pidfs(), ¤t);
|
||||||
HostPidLocator::new(91),
|
|
||||||
ProcessStartTimeTicks::new(910),
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
HostProcessIdentity::legacy_unverified_v1(legacy).verified_pidfs(),
|
|
||||||
Err(LegacyProcessIdentityRefusal)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
PersistedHostProcessOwnership::legacy_unverified(legacy).serviceable_pidfs(),
|
|
||||||
Err(ProcessOwnershipRefusal::LegacyUnverified)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
PersistedHostProcessOwnership::legacy_unverified(legacy).authoritative_pidfs(),
|
|
||||||
Err(ProcessAuthorityRefusal::LegacyUnverified)
|
|
||||||
);
|
|
||||||
|
|
||||||
let staged_anchor = ParticipantOwnership::StateAnchor(state_anchor_ownership(
|
let staged_anchor = ParticipantOwnership::StateAnchor(state_anchor_ownership(
|
||||||
PersistedHostProcessOwnership::staged(current),
|
PersistedHostProcessOwnership::staged(current),
|
||||||
|
|
@ -267,14 +252,9 @@ fn participant_snapshot_matrix_is_phase_complete_and_strictly_decoded() {
|
||||||
#[test]
|
#[test]
|
||||||
fn active_process_owners_require_complete_current_pidfs_evidence() {
|
fn active_process_owners_require_complete_current_pidfs_evidence() {
|
||||||
let current = process(94);
|
let current = process(94);
|
||||||
let legacy = LegacyUnverifiedProcessIdentityV1::new(
|
|
||||||
HostPidLocator::new(94),
|
|
||||||
ProcessStartTimeTicks::new(940),
|
|
||||||
);
|
|
||||||
let invalid_slots = [
|
let invalid_slots = [
|
||||||
PersistedHostProcessOwnership::unassigned(),
|
PersistedHostProcessOwnership::unassigned(),
|
||||||
PersistedHostProcessOwnership::staged(current),
|
PersistedHostProcessOwnership::staged(current),
|
||||||
PersistedHostProcessOwnership::legacy_unverified(legacy),
|
|
||||||
];
|
];
|
||||||
for daemon in invalid_slots {
|
for daemon in invalid_slots {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,6 @@ use super::*;
|
||||||
fn session_terminal_snapshot_and_transition_matrix_is_fail_closed() {
|
fn session_terminal_snapshot_and_transition_matrix_is_fail_closed() {
|
||||||
let current_hc = process(101);
|
let current_hc = process(101);
|
||||||
let current_exec = process(102);
|
let current_exec = process(102);
|
||||||
let legacy = LegacyUnverifiedProcessIdentityV1::new(
|
|
||||||
HostPidLocator::new(102),
|
|
||||||
ProcessStartTimeTicks::new(1_020),
|
|
||||||
);
|
|
||||||
let bare = terminal_snapshot(
|
let bare = terminal_snapshot(
|
||||||
SessionTerminalState::Intent,
|
SessionTerminalState::Intent,
|
||||||
PersistedHostProcessOwnership::unassigned(),
|
PersistedHostProcessOwnership::unassigned(),
|
||||||
|
|
@ -19,7 +15,6 @@ fn session_terminal_snapshot_and_transition_matrix_is_fail_closed() {
|
||||||
for invalid in [
|
for invalid in [
|
||||||
PersistedHostProcessOwnership::unassigned(),
|
PersistedHostProcessOwnership::unassigned(),
|
||||||
PersistedHostProcessOwnership::staged(current_exec),
|
PersistedHostProcessOwnership::staged(current_exec),
|
||||||
PersistedHostProcessOwnership::legacy_unverified(legacy),
|
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
terminal_snapshot(
|
terminal_snapshot(
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
|
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
|
||||||
|
lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" }
|
||||||
|
redb.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
|
||||||
740
crates/lazaret-host-coordinator/src/domain_store.rs
Normal file
740
crates/lazaret-host-coordinator/src/domain_store.rs
Normal file
|
|
@ -0,0 +1,740 @@
|
||||||
|
use std::fmt;
|
||||||
|
use std::io;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use lazaret_core::codec::{CodecError, decode, encode};
|
||||||
|
use lazaret_core::identity::{GenerationId, SessionId};
|
||||||
|
use lazaret_core::records::{
|
||||||
|
Domain, DomainStore, DomainStoreMetadata, DurableDecision, Generation, Mailbox, Participant,
|
||||||
|
ParticipantRole, ParticipantState, RecordTransitionError, Session, SessionPhase,
|
||||||
|
SessionTerminal, SessionTerminalState,
|
||||||
|
};
|
||||||
|
use lazaret_linux::filesystem::ExclusiveFileLock;
|
||||||
|
use redb::{
|
||||||
|
CommitError, Database, DatabaseError, ReadableDatabase, ReadableTable, StorageError,
|
||||||
|
TableDefinition, TableError, TransactionError,
|
||||||
|
};
|
||||||
|
|
||||||
|
const METADATA: TableDefinition<u8, &[u8]> = TableDefinition::new("metadata");
|
||||||
|
const DOMAINS: TableDefinition<u8, &[u8]> = TableDefinition::new("domains");
|
||||||
|
const GENERATIONS: TableDefinition<u64, &[u8]> = TableDefinition::new("generations");
|
||||||
|
const PARTICIPANTS: TableDefinition<&[u8; 9], &[u8]> = TableDefinition::new("participants");
|
||||||
|
const SESSIONS: TableDefinition<&[u8; 40], &[u8]> = TableDefinition::new("sessions");
|
||||||
|
const SESSION_TERMINALS: TableDefinition<&[u8; 40], &[u8]> =
|
||||||
|
TableDefinition::new("session_terminals");
|
||||||
|
const MAILBOXES: TableDefinition<u64, &[u8]> = TableDefinition::new("mailboxes");
|
||||||
|
const SINGLETON_KEY: u8 = 0;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum DomainStoreMutation {
|
||||||
|
PutGeneration(Generation),
|
||||||
|
PutParticipant(Participant),
|
||||||
|
CleanParticipantAfterAbsence(Participant),
|
||||||
|
PutSession(Session),
|
||||||
|
PutMailbox(Mailbox),
|
||||||
|
CleanMailboxAfterAbsence(Mailbox),
|
||||||
|
PutSessionTerminal(SessionTerminal),
|
||||||
|
CleanSessionTerminalAfterAbsence(SessionTerminal),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum DomainStoreError {
|
||||||
|
Io(io::Error),
|
||||||
|
Database(DatabaseError),
|
||||||
|
Transaction(TransactionError),
|
||||||
|
Table(TableError),
|
||||||
|
Storage(StorageError),
|
||||||
|
Commit(CommitError),
|
||||||
|
Codec(CodecError),
|
||||||
|
Transition(RecordTransitionError),
|
||||||
|
MissingMetadata,
|
||||||
|
MissingRow(&'static str),
|
||||||
|
DuplicateRow(&'static str),
|
||||||
|
InvalidRow(&'static str),
|
||||||
|
DomainMismatch,
|
||||||
|
GenerationMismatch,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for DomainStoreError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Io(error) => write!(formatter, "Domain Store I/O failed: {error}"),
|
||||||
|
Self::Database(error) => write!(formatter, "Domain Store open failed: {error}"),
|
||||||
|
Self::Transaction(error) => {
|
||||||
|
write!(formatter, "Domain Store transaction failed: {error}")
|
||||||
|
}
|
||||||
|
Self::Table(error) => write!(formatter, "Domain Store table failed: {error}"),
|
||||||
|
Self::Storage(error) => write!(formatter, "Domain Store storage failed: {error}"),
|
||||||
|
Self::Commit(error) => write!(formatter, "Domain Store commit failed: {error}"),
|
||||||
|
Self::Codec(error) => {
|
||||||
|
write!(formatter, "Domain Store record failed validation: {error}")
|
||||||
|
}
|
||||||
|
Self::Transition(error) => {
|
||||||
|
write!(formatter, "Domain Store transition refused: {error}")
|
||||||
|
}
|
||||||
|
Self::MissingMetadata => formatter.write_str("Domain Store metadata is missing"),
|
||||||
|
Self::MissingRow(kind) => write!(formatter, "referenced {kind} row is missing"),
|
||||||
|
Self::DuplicateRow(kind) => write!(formatter, "duplicate {kind} row"),
|
||||||
|
Self::InvalidRow(kind) => write!(formatter, "invalid {kind} row"),
|
||||||
|
Self::DomainMismatch => formatter.write_str("Domain Store Domain identity mismatch"),
|
||||||
|
Self::GenerationMismatch => formatter.write_str("Domain Store generation mismatch"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for DomainStoreError {
|
||||||
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Io(error) => Some(error),
|
||||||
|
Self::Database(error) => Some(error),
|
||||||
|
Self::Transaction(error) => Some(error),
|
||||||
|
Self::Table(error) => Some(error),
|
||||||
|
Self::Storage(error) => Some(error),
|
||||||
|
Self::Commit(error) => Some(error),
|
||||||
|
Self::Codec(error) => Some(error),
|
||||||
|
Self::Transition(error) => Some(error),
|
||||||
|
Self::MissingMetadata
|
||||||
|
| Self::MissingRow(_)
|
||||||
|
| Self::DuplicateRow(_)
|
||||||
|
| Self::InvalidRow(_)
|
||||||
|
| Self::DomainMismatch
|
||||||
|
| Self::GenerationMismatch => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! from_error {
|
||||||
|
($source:ty, $variant:ident) => {
|
||||||
|
impl From<$source> for DomainStoreError {
|
||||||
|
fn from(error: $source) -> Self {
|
||||||
|
Self::$variant(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
from_error!(io::Error, Io);
|
||||||
|
from_error!(DatabaseError, Database);
|
||||||
|
from_error!(TransactionError, Transaction);
|
||||||
|
from_error!(TableError, Table);
|
||||||
|
from_error!(StorageError, Storage);
|
||||||
|
from_error!(CommitError, Commit);
|
||||||
|
from_error!(CodecError, Codec);
|
||||||
|
from_error!(RecordTransitionError, Transition);
|
||||||
|
|
||||||
|
pub struct HostCoordinatorDomainStore {
|
||||||
|
database: Database,
|
||||||
|
_domain_lock: ExclusiveFileLock,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HostCoordinatorDomainStore {
|
||||||
|
/// Opens the Domain Store while retaining the caller's exclusive Domain lock.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Refuses I/O, database, metadata, row, or Domain identity failures.
|
||||||
|
pub fn open(
|
||||||
|
domain_lock: ExclusiveFileLock,
|
||||||
|
database_path: &Path,
|
||||||
|
expected_domain: &Domain,
|
||||||
|
) -> Result<Self, DomainStoreError> {
|
||||||
|
let is_new = !database_path.try_exists()?;
|
||||||
|
let database = if is_new {
|
||||||
|
Database::create(database_path)?
|
||||||
|
} else {
|
||||||
|
Database::open(database_path)?
|
||||||
|
};
|
||||||
|
let store = Self {
|
||||||
|
database,
|
||||||
|
_domain_lock: domain_lock,
|
||||||
|
};
|
||||||
|
if is_new {
|
||||||
|
store.initialize(expected_domain)?;
|
||||||
|
} else {
|
||||||
|
store.validate_metadata()?;
|
||||||
|
if store.snapshot()?.domain != *expected_domain {
|
||||||
|
return Err(DomainStoreError::DomainMismatch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads and validates every typed row as one consistent redb snapshot.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Refuses database, table, codec, key, duplicate, or relationship failures.
|
||||||
|
pub fn snapshot(&self) -> Result<DomainStore, DomainStoreError> {
|
||||||
|
self.validate_metadata()?;
|
||||||
|
let read = self.database.begin_read()?;
|
||||||
|
let domain = {
|
||||||
|
let table = read.open_table(DOMAINS)?;
|
||||||
|
let value = table
|
||||||
|
.get(SINGLETON_KEY)?
|
||||||
|
.ok_or(DomainStoreError::MissingRow("Domain"))?;
|
||||||
|
decode(value.value())?
|
||||||
|
};
|
||||||
|
let generations = read_generations(&read.open_table(GENERATIONS)?)?;
|
||||||
|
let participants = read_participants(&read.open_table(PARTICIPANTS)?)?;
|
||||||
|
let sessions = read_sessions(&read.open_table(SESSIONS)?)?;
|
||||||
|
let session_terminals = read_session_terminals(&read.open_table(SESSION_TERMINALS)?)?;
|
||||||
|
let mailboxes = read_mailboxes(&read.open_table(MAILBOXES)?)?;
|
||||||
|
let snapshot = DomainStore {
|
||||||
|
metadata: DomainStoreMetadata::v1(),
|
||||||
|
domain,
|
||||||
|
generations,
|
||||||
|
participants,
|
||||||
|
sessions,
|
||||||
|
session_terminals,
|
||||||
|
mailboxes,
|
||||||
|
};
|
||||||
|
validate_snapshot(&snapshot)?;
|
||||||
|
Ok(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the complete mutation batch in one immediate-durability redb transaction.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Refuses invalid transitions, generation mismatches, malformed relationships, or storage
|
||||||
|
/// failures without committing any member of the batch.
|
||||||
|
pub fn apply(&self, mutations: &[DomainStoreMutation]) -> Result<(), DomainStoreError> {
|
||||||
|
if mutations.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let mut snapshot = self.snapshot()?;
|
||||||
|
for mutation in mutations {
|
||||||
|
apply_mutation(&mut snapshot, mutation)?;
|
||||||
|
}
|
||||||
|
validate_snapshot(&snapshot)?;
|
||||||
|
self.write_snapshot(&snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initialize(&self, domain: &Domain) -> Result<(), DomainStoreError> {
|
||||||
|
let snapshot = DomainStore {
|
||||||
|
metadata: DomainStoreMetadata::v1(),
|
||||||
|
domain: domain.clone(),
|
||||||
|
generations: Vec::new(),
|
||||||
|
participants: Vec::new(),
|
||||||
|
sessions: Vec::new(),
|
||||||
|
session_terminals: Vec::new(),
|
||||||
|
mailboxes: Vec::new(),
|
||||||
|
};
|
||||||
|
self.write_snapshot(&snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_metadata(&self) -> Result<(), DomainStoreError> {
|
||||||
|
let read = self.database.begin_read()?;
|
||||||
|
let table = read.open_table(METADATA).map_err(|error| match error {
|
||||||
|
TableError::TableDoesNotExist(_) => DomainStoreError::MissingMetadata,
|
||||||
|
other => DomainStoreError::Table(other),
|
||||||
|
})?;
|
||||||
|
let value = table
|
||||||
|
.get(SINGLETON_KEY)?
|
||||||
|
.ok_or(DomainStoreError::MissingMetadata)?;
|
||||||
|
let _: DomainStoreMetadata = decode(value.value())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_snapshot(&self, snapshot: &DomainStore) -> Result<(), DomainStoreError> {
|
||||||
|
let write = self.database.begin_write()?;
|
||||||
|
{
|
||||||
|
let mut table = write.open_table(METADATA)?;
|
||||||
|
let encoded = encode(&snapshot.metadata)?;
|
||||||
|
table.insert(SINGLETON_KEY, encoded.as_slice())?;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut table = write.open_table(DOMAINS)?;
|
||||||
|
let encoded = encode(&snapshot.domain)?;
|
||||||
|
table.insert(SINGLETON_KEY, encoded.as_slice())?;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut table = write.open_table(GENERATIONS)?;
|
||||||
|
for record in &snapshot.generations {
|
||||||
|
let encoded = encode(record)?;
|
||||||
|
table.insert(record.generation_id.get(), encoded.as_slice())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut table = write.open_table(PARTICIPANTS)?;
|
||||||
|
for record in &snapshot.participants {
|
||||||
|
let encoded = encode(record)?;
|
||||||
|
table.insert(&participant_key(record), encoded.as_slice())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut table = write.open_table(SESSIONS)?;
|
||||||
|
for record in &snapshot.sessions {
|
||||||
|
let encoded = encode(record)?;
|
||||||
|
table.insert(
|
||||||
|
&session_key(record.generation_id, record.session_id),
|
||||||
|
encoded.as_slice(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut table = write.open_table(SESSION_TERMINALS)?;
|
||||||
|
for record in &snapshot.session_terminals {
|
||||||
|
let encoded = encode(record)?;
|
||||||
|
table.insert(
|
||||||
|
&session_key(record.generation_id(), record.session_id()),
|
||||||
|
encoded.as_slice(),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut table = write.open_table(MAILBOXES)?;
|
||||||
|
for record in &snapshot.mailboxes {
|
||||||
|
let encoded = encode(record)?;
|
||||||
|
table.insert(record.generation_id.get(), encoded.as_slice())?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_generations(
|
||||||
|
table: &redb::ReadOnlyTable<u64, &[u8]>,
|
||||||
|
) -> Result<Vec<Generation>, DomainStoreError> {
|
||||||
|
let mut records = Vec::new();
|
||||||
|
for entry in table.iter()? {
|
||||||
|
let (key, value) = entry?;
|
||||||
|
let record: Generation = decode(value.value())?;
|
||||||
|
if key.value() != record.generation_id.get() {
|
||||||
|
return Err(DomainStoreError::InvalidRow("Generation key"));
|
||||||
|
}
|
||||||
|
records.push(record);
|
||||||
|
}
|
||||||
|
Ok(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_participants(
|
||||||
|
table: &redb::ReadOnlyTable<&[u8; 9], &[u8]>,
|
||||||
|
) -> Result<Vec<Participant>, DomainStoreError> {
|
||||||
|
let mut records = Vec::new();
|
||||||
|
for entry in table.iter()? {
|
||||||
|
let (key, value) = entry?;
|
||||||
|
let record: Participant = decode(value.value())?;
|
||||||
|
if key.value() != &participant_key(&record) {
|
||||||
|
return Err(DomainStoreError::InvalidRow("Participant key"));
|
||||||
|
}
|
||||||
|
records.push(record);
|
||||||
|
}
|
||||||
|
Ok(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_sessions(
|
||||||
|
table: &redb::ReadOnlyTable<&[u8; 40], &[u8]>,
|
||||||
|
) -> Result<Vec<Session>, DomainStoreError> {
|
||||||
|
let mut records = Vec::new();
|
||||||
|
for entry in table.iter()? {
|
||||||
|
let (key, value) = entry?;
|
||||||
|
let record: Session = decode(value.value())?;
|
||||||
|
if key.value() != &session_key(record.generation_id, record.session_id) {
|
||||||
|
return Err(DomainStoreError::InvalidRow("Session key"));
|
||||||
|
}
|
||||||
|
records.push(record);
|
||||||
|
}
|
||||||
|
Ok(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_session_terminals(
|
||||||
|
table: &redb::ReadOnlyTable<&[u8; 40], &[u8]>,
|
||||||
|
) -> Result<Vec<SessionTerminal>, DomainStoreError> {
|
||||||
|
let mut records = Vec::new();
|
||||||
|
for entry in table.iter()? {
|
||||||
|
let (key, value) = entry?;
|
||||||
|
let record: SessionTerminal = decode(value.value())?;
|
||||||
|
if key.value() != &session_key(record.generation_id(), record.session_id()) {
|
||||||
|
return Err(DomainStoreError::InvalidRow("Session Terminal key"));
|
||||||
|
}
|
||||||
|
records.push(record);
|
||||||
|
}
|
||||||
|
Ok(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_mailboxes(
|
||||||
|
table: &redb::ReadOnlyTable<u64, &[u8]>,
|
||||||
|
) -> Result<Vec<Mailbox>, DomainStoreError> {
|
||||||
|
let mut records = Vec::new();
|
||||||
|
for entry in table.iter()? {
|
||||||
|
let (key, value) = entry?;
|
||||||
|
let record: Mailbox = decode(value.value())?;
|
||||||
|
if key.value() != record.generation_id.get() {
|
||||||
|
return Err(DomainStoreError::InvalidRow("Mailbox key"));
|
||||||
|
}
|
||||||
|
records.push(record);
|
||||||
|
}
|
||||||
|
Ok(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_mutation(
|
||||||
|
snapshot: &mut DomainStore,
|
||||||
|
mutation: &DomainStoreMutation,
|
||||||
|
) -> Result<(), DomainStoreError> {
|
||||||
|
match mutation {
|
||||||
|
DomainStoreMutation::PutGeneration(next) => put_generation(snapshot, next.clone()),
|
||||||
|
DomainStoreMutation::PutParticipant(next) => put_participant(snapshot, next.clone(), false),
|
||||||
|
DomainStoreMutation::CleanParticipantAfterAbsence(next) => {
|
||||||
|
put_participant(snapshot, next.clone(), true)
|
||||||
|
}
|
||||||
|
DomainStoreMutation::PutSession(next) => put_session(snapshot, next.clone()),
|
||||||
|
DomainStoreMutation::PutMailbox(next) => put_mailbox(snapshot, next.clone(), false),
|
||||||
|
DomainStoreMutation::CleanMailboxAfterAbsence(next) => {
|
||||||
|
put_mailbox(snapshot, next.clone(), true)
|
||||||
|
}
|
||||||
|
DomainStoreMutation::PutSessionTerminal(next) => {
|
||||||
|
put_session_terminal(snapshot, next.clone(), false)
|
||||||
|
}
|
||||||
|
DomainStoreMutation::CleanSessionTerminalAfterAbsence(next) => {
|
||||||
|
put_session_terminal(snapshot, next.clone(), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_generation(snapshot: &mut DomainStore, next: Generation) -> Result<(), DomainStoreError> {
|
||||||
|
if next.domain_id != snapshot.domain.domain_id {
|
||||||
|
return Err(DomainStoreError::DomainMismatch);
|
||||||
|
}
|
||||||
|
if let Some(current) = snapshot
|
||||||
|
.generations
|
||||||
|
.iter_mut()
|
||||||
|
.find(|record| record.generation_id == next.generation_id)
|
||||||
|
{
|
||||||
|
validate_generation_transition(current, &next)?;
|
||||||
|
*current = next;
|
||||||
|
} else {
|
||||||
|
if next.durable_decision != DurableDecision::None {
|
||||||
|
return Err(DomainStoreError::InvalidRow("initial Generation"));
|
||||||
|
}
|
||||||
|
snapshot.generations.push(next);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_participant(
|
||||||
|
snapshot: &mut DomainStore,
|
||||||
|
next: Participant,
|
||||||
|
absence_proven: bool,
|
||||||
|
) -> Result<(), DomainStoreError> {
|
||||||
|
require_generation(snapshot, next.generation_id())?;
|
||||||
|
if let Some(current) = snapshot.participants.iter_mut().find(|record| {
|
||||||
|
record.generation_id() == next.generation_id() && record.role() == next.role()
|
||||||
|
}) {
|
||||||
|
if absence_proven {
|
||||||
|
current.validate_transition_to_clean_after_absence(&next)?;
|
||||||
|
} else {
|
||||||
|
current.validate_transition_to(&next)?;
|
||||||
|
}
|
||||||
|
*current = next;
|
||||||
|
} else {
|
||||||
|
if absence_proven || next.state() != ParticipantState::Intent || next.ownership().is_some()
|
||||||
|
{
|
||||||
|
return Err(DomainStoreError::InvalidRow("initial Participant"));
|
||||||
|
}
|
||||||
|
snapshot.participants.push(next);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_session(snapshot: &mut DomainStore, next: Session) -> Result<(), DomainStoreError> {
|
||||||
|
require_generation(snapshot, next.generation_id)?;
|
||||||
|
if let Some(current) = snapshot.sessions.iter_mut().find(|record| {
|
||||||
|
record.generation_id == next.generation_id && record.session_id == next.session_id
|
||||||
|
}) {
|
||||||
|
validate_session_transition(current, &next)?;
|
||||||
|
*current = next;
|
||||||
|
} else {
|
||||||
|
if !matches!(
|
||||||
|
next.session_phase,
|
||||||
|
SessionPhase::New | SessionPhase::Waiting | SessionPhase::Registering
|
||||||
|
) {
|
||||||
|
return Err(DomainStoreError::InvalidRow("initial Session"));
|
||||||
|
}
|
||||||
|
snapshot.sessions.push(next);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_mailbox(
|
||||||
|
snapshot: &mut DomainStore,
|
||||||
|
next: Mailbox,
|
||||||
|
absence_proven: bool,
|
||||||
|
) -> Result<(), DomainStoreError> {
|
||||||
|
require_generation(snapshot, next.generation_id)?;
|
||||||
|
if let Some(current) = snapshot
|
||||||
|
.mailboxes
|
||||||
|
.iter_mut()
|
||||||
|
.find(|record| record.generation_id == next.generation_id)
|
||||||
|
{
|
||||||
|
validate_mailbox_transition(current, &next, absence_proven)?;
|
||||||
|
*current = next;
|
||||||
|
} else {
|
||||||
|
if absence_proven || next.state != ParticipantState::Intent {
|
||||||
|
return Err(DomainStoreError::InvalidRow("initial Mailbox"));
|
||||||
|
}
|
||||||
|
snapshot.mailboxes.push(next);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put_session_terminal(
|
||||||
|
snapshot: &mut DomainStore,
|
||||||
|
next: SessionTerminal,
|
||||||
|
absence_proven: bool,
|
||||||
|
) -> Result<(), DomainStoreError> {
|
||||||
|
require_session(snapshot, next.generation_id(), next.session_id())?;
|
||||||
|
if let Some(current) = snapshot.session_terminals.iter_mut().find(|record| {
|
||||||
|
record.generation_id() == next.generation_id() && record.session_id() == next.session_id()
|
||||||
|
}) {
|
||||||
|
if absence_proven {
|
||||||
|
current.validate_transition_to_clean_after_absence(&next)?;
|
||||||
|
} else {
|
||||||
|
current.validate_transition_to(&next)?;
|
||||||
|
}
|
||||||
|
*current = next;
|
||||||
|
} else {
|
||||||
|
if absence_proven
|
||||||
|
|| next.state() != SessionTerminalState::Intent
|
||||||
|
|| next.container_id().is_some()
|
||||||
|
|| !next.host_coordinator().is_unassigned()
|
||||||
|
|| next.pty_master().is_some()
|
||||||
|
|| !next.exec_client().is_unassigned()
|
||||||
|
|| next.guest_process().is_some()
|
||||||
|
{
|
||||||
|
return Err(DomainStoreError::InvalidRow("initial Session Terminal"));
|
||||||
|
}
|
||||||
|
snapshot.session_terminals.push(next);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_generation_transition(
|
||||||
|
current: &Generation,
|
||||||
|
next: &Generation,
|
||||||
|
) -> Result<(), DomainStoreError> {
|
||||||
|
if current.domain_id != next.domain_id
|
||||||
|
|| current.generation_id != next.generation_id
|
||||||
|
|| current.configuration_fingerprint != next.configuration_fingerprint
|
||||||
|
|| current.coordinator_id != next.coordinator_id
|
||||||
|
|| current.network_sidecar_id != next.network_sidecar_id
|
||||||
|
|| current.sandbox_id != next.sandbox_id
|
||||||
|
{
|
||||||
|
return Err(DomainStoreError::InvalidRow("Generation identity"));
|
||||||
|
}
|
||||||
|
let valid = current.durable_decision == next.durable_decision
|
||||||
|
|| matches!(
|
||||||
|
(current.durable_decision, next.durable_decision),
|
||||||
|
(
|
||||||
|
DurableDecision::None,
|
||||||
|
DurableDecision::Preparing | DurableDecision::Clean
|
||||||
|
) | (
|
||||||
|
DurableDecision::Preparing,
|
||||||
|
DurableDecision::Published | DurableDecision::Clean
|
||||||
|
) | (DurableDecision::Published, DurableDecision::Clean)
|
||||||
|
);
|
||||||
|
if valid {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(DomainStoreError::InvalidRow("Generation transition"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_session_transition(current: &Session, next: &Session) -> Result<(), DomainStoreError> {
|
||||||
|
if current.generation_id != next.generation_id
|
||||||
|
|| current.session_id != next.session_id
|
||||||
|
|| current.cli_id != next.cli_id
|
||||||
|
|| current.session_anchor_id != next.session_anchor_id
|
||||||
|
{
|
||||||
|
return Err(DomainStoreError::InvalidRow("Session identity"));
|
||||||
|
}
|
||||||
|
let valid = current.session_phase == next.session_phase
|
||||||
|
|| matches!(
|
||||||
|
(current.session_phase, next.session_phase),
|
||||||
|
(
|
||||||
|
SessionPhase::New,
|
||||||
|
SessionPhase::Waiting | SessionPhase::Registering | SessionPhase::Failed
|
||||||
|
) | (
|
||||||
|
SessionPhase::Waiting,
|
||||||
|
SessionPhase::Registering | SessionPhase::Closed | SessionPhase::Failed
|
||||||
|
) | (
|
||||||
|
SessionPhase::Registering | SessionPhase::HostExecuting,
|
||||||
|
SessionPhase::Attached | SessionPhase::Closed | SessionPhase::Failed
|
||||||
|
) | (
|
||||||
|
SessionPhase::Attached,
|
||||||
|
SessionPhase::Prompting
|
||||||
|
| SessionPhase::HostExecuting
|
||||||
|
| SessionPhase::Closed
|
||||||
|
| SessionPhase::Failed
|
||||||
|
) | (
|
||||||
|
SessionPhase::Prompting,
|
||||||
|
SessionPhase::Attached
|
||||||
|
| SessionPhase::HostExecuting
|
||||||
|
| SessionPhase::Closed
|
||||||
|
| SessionPhase::Failed
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if valid {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(DomainStoreError::InvalidRow("Session transition"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_mailbox_transition(
|
||||||
|
current: &Mailbox,
|
||||||
|
next: &Mailbox,
|
||||||
|
absence_proven: bool,
|
||||||
|
) -> Result<(), DomainStoreError> {
|
||||||
|
if current.generation_id != next.generation_id
|
||||||
|
|| current.identity_path != next.identity_path
|
||||||
|
|| current.inbox_path != next.inbox_path
|
||||||
|
|| current.outbox_path != next.outbox_path
|
||||||
|
{
|
||||||
|
return Err(DomainStoreError::InvalidRow("Mailbox identity"));
|
||||||
|
}
|
||||||
|
if absence_proven {
|
||||||
|
return if matches!(
|
||||||
|
current.state,
|
||||||
|
ParticipantState::Intent | ParticipantState::ReleaseIntent
|
||||||
|
) && next.state == ParticipantState::Clean
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(DomainStoreError::InvalidRow("Mailbox cleanup"))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let valid = current.state == next.state
|
||||||
|
|| matches!(
|
||||||
|
(current.state, next.state),
|
||||||
|
(
|
||||||
|
ParticipantState::Intent,
|
||||||
|
ParticipantState::LeaseHeld
|
||||||
|
| ParticipantState::Prepared
|
||||||
|
| ParticipantState::Committed
|
||||||
|
| ParticipantState::ReleaseIntent
|
||||||
|
) | (
|
||||||
|
ParticipantState::LeaseHeld
|
||||||
|
| ParticipantState::Prepared
|
||||||
|
| ParticipantState::Committed,
|
||||||
|
ParticipantState::ReleaseIntent
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if valid {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(DomainStoreError::InvalidRow("Mailbox transition"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_snapshot(snapshot: &DomainStore) -> Result<(), DomainStoreError> {
|
||||||
|
for (index, generation) in snapshot.generations.iter().enumerate() {
|
||||||
|
if generation.domain_id != snapshot.domain.domain_id {
|
||||||
|
return Err(DomainStoreError::DomainMismatch);
|
||||||
|
}
|
||||||
|
if snapshot.generations[..index]
|
||||||
|
.iter()
|
||||||
|
.any(|other| other.generation_id == generation.generation_id)
|
||||||
|
{
|
||||||
|
return Err(DomainStoreError::DuplicateRow("Generation"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (index, participant) in snapshot.participants.iter().enumerate() {
|
||||||
|
require_generation(snapshot, participant.generation_id())?;
|
||||||
|
if snapshot.participants[..index].iter().any(|other| {
|
||||||
|
other.generation_id() == participant.generation_id()
|
||||||
|
&& other.role() == participant.role()
|
||||||
|
}) {
|
||||||
|
return Err(DomainStoreError::DuplicateRow("Participant"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (index, session) in snapshot.sessions.iter().enumerate() {
|
||||||
|
require_generation(snapshot, session.generation_id)?;
|
||||||
|
if snapshot.sessions[..index].iter().any(|other| {
|
||||||
|
other.generation_id == session.generation_id && other.session_id == session.session_id
|
||||||
|
}) {
|
||||||
|
return Err(DomainStoreError::DuplicateRow("Session"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (index, terminal) in snapshot.session_terminals.iter().enumerate() {
|
||||||
|
require_session(snapshot, terminal.generation_id(), terminal.session_id())?;
|
||||||
|
if snapshot.session_terminals[..index].iter().any(|other| {
|
||||||
|
other.generation_id() == terminal.generation_id()
|
||||||
|
&& other.session_id() == terminal.session_id()
|
||||||
|
}) {
|
||||||
|
return Err(DomainStoreError::DuplicateRow("Session Terminal"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (index, mailbox) in snapshot.mailboxes.iter().enumerate() {
|
||||||
|
require_generation(snapshot, mailbox.generation_id)?;
|
||||||
|
if snapshot.mailboxes[..index]
|
||||||
|
.iter()
|
||||||
|
.any(|other| other.generation_id == mailbox.generation_id)
|
||||||
|
{
|
||||||
|
return Err(DomainStoreError::DuplicateRow("Mailbox"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_generation(
|
||||||
|
snapshot: &DomainStore,
|
||||||
|
generation_id: GenerationId,
|
||||||
|
) -> Result<(), DomainStoreError> {
|
||||||
|
if snapshot
|
||||||
|
.generations
|
||||||
|
.iter()
|
||||||
|
.any(|record| record.generation_id == generation_id)
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(DomainStoreError::GenerationMismatch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_session(
|
||||||
|
snapshot: &DomainStore,
|
||||||
|
generation_id: GenerationId,
|
||||||
|
session_id: SessionId,
|
||||||
|
) -> Result<(), DomainStoreError> {
|
||||||
|
require_generation(snapshot, generation_id)?;
|
||||||
|
if snapshot
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.any(|record| record.generation_id == generation_id && record.session_id == session_id)
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(DomainStoreError::MissingRow("Session"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn participant_key(record: &Participant) -> [u8; 9] {
|
||||||
|
let mut key = [0; 9];
|
||||||
|
key[..8].copy_from_slice(&record.generation_id().get().to_be_bytes());
|
||||||
|
key[8] = role_key(record.role());
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
fn role_key(role: ParticipantRole) -> u8 {
|
||||||
|
match role {
|
||||||
|
ParticipantRole::PreparedLower => 0,
|
||||||
|
ParticipantRole::StateAnchor => 1,
|
||||||
|
ParticipantRole::GitRouting => 2,
|
||||||
|
ParticipantRole::Network => 3,
|
||||||
|
ParticipantRole::Helper => 4,
|
||||||
|
ParticipantRole::Sandbox => 5,
|
||||||
|
ParticipantRole::Publication => 6,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_key(generation_id: GenerationId, session_id: SessionId) -> [u8; 40] {
|
||||||
|
let mut key = [0; 40];
|
||||||
|
key[..8].copy_from_slice(&generation_id.get().to_be_bytes());
|
||||||
|
key[8..].copy_from_slice(session_id.as_bytes());
|
||||||
|
key
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
374
crates/lazaret-host-coordinator/src/domain_store/tests.rs
Normal file
374
crates/lazaret-host-coordinator/src/domain_store/tests.rs
Normal file
|
|
@ -0,0 +1,374 @@
|
||||||
|
use std::fs::{File, OpenOptions};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
use lazaret_core::codec::encode;
|
||||||
|
use lazaret_core::identity::{
|
||||||
|
ClientId, ConfigurationFingerprint, CoordinatorId, DomainId, GenerationId, HostPath,
|
||||||
|
NetworkSidecarId, SandboxId, SessionAnchorId, SessionId,
|
||||||
|
};
|
||||||
|
use lazaret_core::records::{
|
||||||
|
DurableDecision, ParticipantOwnership, PersistedHostProcessOwnership, PublicationOwnership,
|
||||||
|
SessionTerminalFields,
|
||||||
|
};
|
||||||
|
use lazaret_linux::filesystem::try_exclusive_flock;
|
||||||
|
use redb::{ReadableDatabase, TableDefinition, TableHandle};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0);
|
||||||
|
const FOREIGN: TableDefinition<u8, u8> = TableDefinition::new("foreign");
|
||||||
|
|
||||||
|
struct TestDirectory(PathBuf);
|
||||||
|
|
||||||
|
impl TestDirectory {
|
||||||
|
fn new(label: &str) -> Self {
|
||||||
|
let sequence = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"lazaret-domain-store-{label}-{}-{sequence}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
std::fs::create_dir(&path).unwrap();
|
||||||
|
Self(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(&self, name: &str) -> PathBuf {
|
||||||
|
self.0.join(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TestDirectory {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
std::fs::remove_dir_all(&self.0).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes(value: u8) -> [u8; 32] {
|
||||||
|
[value; 32]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn domain() -> Domain {
|
||||||
|
Domain {
|
||||||
|
domain_id: DomainId::new(bytes(1)),
|
||||||
|
profile_name: "trusted".into(),
|
||||||
|
canonical_workspace: HostPath::new(b"/workspace/project".to_vec()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generation(decision: DurableDecision) -> Generation {
|
||||||
|
Generation {
|
||||||
|
domain_id: domain().domain_id,
|
||||||
|
generation_id: GenerationId::new(7),
|
||||||
|
configuration_fingerprint: ConfigurationFingerprint::new(bytes(2)),
|
||||||
|
coordinator_id: CoordinatorId::new(bytes(3)),
|
||||||
|
network_sidecar_id: NetworkSidecarId::new(bytes(4)),
|
||||||
|
sandbox_id: SandboxId::new(bytes(5)),
|
||||||
|
durable_decision: decision,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session(phase: SessionPhase) -> Session {
|
||||||
|
Session {
|
||||||
|
generation_id: GenerationId::new(7),
|
||||||
|
session_id: SessionId::new(bytes(6)),
|
||||||
|
cli_id: ClientId::new(bytes(7)),
|
||||||
|
session_anchor_id: SessionAnchorId::new(bytes(8)),
|
||||||
|
session_phase: phase,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mailbox(state: ParticipantState) -> Mailbox {
|
||||||
|
Mailbox {
|
||||||
|
generation_id: GenerationId::new(7),
|
||||||
|
identity_path: HostPath::new(b"identity".to_vec()),
|
||||||
|
inbox_path: HostPath::new(b"inbox".to_vec()),
|
||||||
|
outbox_path: HostPath::new(b"outbox".to_vec()),
|
||||||
|
state,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bare_terminal(state: SessionTerminalState) -> SessionTerminal {
|
||||||
|
SessionTerminal::snapshot(SessionTerminalFields {
|
||||||
|
generation_id: GenerationId::new(7),
|
||||||
|
session_id: SessionId::new(bytes(6)),
|
||||||
|
state,
|
||||||
|
container_id: None,
|
||||||
|
host_coordinator: PersistedHostProcessOwnership::unassigned(),
|
||||||
|
pty_master: None,
|
||||||
|
exec_client: PersistedHostProcessOwnership::unassigned(),
|
||||||
|
guest_process: None,
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publication_intent() -> Participant {
|
||||||
|
Participant::bare_intent(GenerationId::new(7), ParticipantRole::Publication)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publication_partial() -> Participant {
|
||||||
|
Participant::snapshot(
|
||||||
|
GenerationId::new(7),
|
||||||
|
ParticipantRole::Publication,
|
||||||
|
ParticipantState::Intent,
|
||||||
|
Some(ParticipantOwnership::Publication(PublicationOwnership {
|
||||||
|
socket_path: Some(HostPath::new(b"control.sock".to_vec())),
|
||||||
|
identity_path: None,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock_file(path: &Path) -> File {
|
||||||
|
OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.write(true)
|
||||||
|
.create(true)
|
||||||
|
.truncate(false)
|
||||||
|
.open(path)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_store(directory: &TestDirectory) -> HostCoordinatorDomainStore {
|
||||||
|
let lock = try_exclusive_flock(lock_file(&directory.path("domain.lock"))).unwrap();
|
||||||
|
HostCoordinatorDomainStore::open(lock, &directory.path("domain.redb"), &domain()).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transaction_reopen_and_interrupted_write_preserve_complete_documents() {
|
||||||
|
let directory = TestDirectory::new("transaction");
|
||||||
|
let store = open_store(&directory);
|
||||||
|
store
|
||||||
|
.apply(&[
|
||||||
|
DomainStoreMutation::PutGeneration(generation(DurableDecision::None)),
|
||||||
|
DomainStoreMutation::PutParticipant(publication_intent()),
|
||||||
|
DomainStoreMutation::PutSession(session(SessionPhase::New)),
|
||||||
|
DomainStoreMutation::PutMailbox(mailbox(ParticipantState::Intent)),
|
||||||
|
DomainStoreMutation::PutSessionTerminal(bare_terminal(SessionTerminalState::Intent)),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let refused = store.apply(&[
|
||||||
|
DomainStoreMutation::PutGeneration(generation(DurableDecision::Preparing)),
|
||||||
|
DomainStoreMutation::PutSession(session(SessionPhase::Attached)),
|
||||||
|
]);
|
||||||
|
assert!(matches!(
|
||||||
|
refused,
|
||||||
|
Err(DomainStoreError::InvalidRow("Session transition"))
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
store.snapshot().unwrap().generations[0].durable_decision,
|
||||||
|
DurableDecision::None
|
||||||
|
);
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
let database = Database::open(directory.path("domain.redb")).unwrap();
|
||||||
|
let write = database.begin_write().unwrap();
|
||||||
|
{
|
||||||
|
let mut table = write.open_table(GENERATIONS).unwrap();
|
||||||
|
let encoded = encode(&generation(DurableDecision::Preparing)).unwrap();
|
||||||
|
table.insert(7, encoded.as_slice()).unwrap();
|
||||||
|
}
|
||||||
|
drop(write);
|
||||||
|
drop(database);
|
||||||
|
|
||||||
|
let reopened = open_store(&directory);
|
||||||
|
let snapshot = reopened.snapshot().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.generations[0].durable_decision,
|
||||||
|
DurableDecision::None
|
||||||
|
);
|
||||||
|
assert_eq!(snapshot.participants.len(), 1);
|
||||||
|
assert_eq!(snapshot.sessions.len(), 1);
|
||||||
|
assert_eq!(snapshot.mailboxes.len(), 1);
|
||||||
|
assert_eq!(snapshot.session_terminals.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opener_retains_the_domain_lock_for_its_complete_lifetime() {
|
||||||
|
let directory = TestDirectory::new("opener");
|
||||||
|
let store = open_store(&directory);
|
||||||
|
assert!(try_exclusive_flock(lock_file(&directory.path("domain.lock"))).is_err());
|
||||||
|
assert!(matches!(
|
||||||
|
Database::open(directory.path("domain.redb")),
|
||||||
|
Err(DatabaseError::DatabaseAlreadyOpen)
|
||||||
|
));
|
||||||
|
drop(store);
|
||||||
|
assert!(try_exclusive_flock(lock_file(&directory.path("domain.lock"))).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metadata_and_typed_row_validation_fail_closed() {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct RawMetadata<'a> {
|
||||||
|
schema_id: &'a str,
|
||||||
|
schema_version: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
let directory = TestDirectory::new("metadata");
|
||||||
|
drop(open_store(&directory));
|
||||||
|
let database = Database::open(directory.path("domain.redb")).unwrap();
|
||||||
|
let write = database.begin_write().unwrap();
|
||||||
|
{
|
||||||
|
let mut table = write.open_table(METADATA).unwrap();
|
||||||
|
let encoded = encode(&RawMetadata {
|
||||||
|
schema_id: "lazaret-domain-store-v1",
|
||||||
|
schema_version: 2,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
table.insert(SINGLETON_KEY, encoded.as_slice()).unwrap();
|
||||||
|
}
|
||||||
|
write.commit().unwrap();
|
||||||
|
drop(database);
|
||||||
|
let lock = try_exclusive_flock(lock_file(&directory.path("domain.lock"))).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
HostCoordinatorDomainStore::open(lock, &directory.path("domain.redb"), &domain()),
|
||||||
|
Err(DomainStoreError::Codec(_))
|
||||||
|
));
|
||||||
|
|
||||||
|
let row_directory = TestDirectory::new("row-key");
|
||||||
|
let store = open_store(&row_directory);
|
||||||
|
store
|
||||||
|
.apply(&[DomainStoreMutation::PutGeneration(generation(
|
||||||
|
DurableDecision::None,
|
||||||
|
))])
|
||||||
|
.unwrap();
|
||||||
|
drop(store);
|
||||||
|
let database = Database::open(row_directory.path("domain.redb")).unwrap();
|
||||||
|
let write = database.begin_write().unwrap();
|
||||||
|
{
|
||||||
|
let mut table = write.open_table(GENERATIONS).unwrap();
|
||||||
|
let encoded = encode(&generation(DurableDecision::None)).unwrap();
|
||||||
|
table.insert(8, encoded.as_slice()).unwrap();
|
||||||
|
}
|
||||||
|
write.commit().unwrap();
|
||||||
|
drop(database);
|
||||||
|
let lock = try_exclusive_flock(lock_file(&row_directory.path("domain.lock"))).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
HostCoordinatorDomainStore::open(lock, &row_directory.path("domain.redb"), &domain()),
|
||||||
|
Err(DomainStoreError::InvalidRow("Generation key"))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generation_relationships_and_domain_identity_cannot_be_forged() {
|
||||||
|
let directory = TestDirectory::new("relationships");
|
||||||
|
let store = open_store(&directory);
|
||||||
|
assert!(matches!(
|
||||||
|
store.apply(&[DomainStoreMutation::PutParticipant(publication_intent())]),
|
||||||
|
Err(DomainStoreError::GenerationMismatch)
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut wrong_domain = generation(DurableDecision::None);
|
||||||
|
wrong_domain.domain_id = DomainId::new(bytes(99));
|
||||||
|
assert!(matches!(
|
||||||
|
store.apply(&[DomainStoreMutation::PutGeneration(wrong_domain)]),
|
||||||
|
Err(DomainStoreError::DomainMismatch)
|
||||||
|
));
|
||||||
|
store
|
||||||
|
.apply(&[DomainStoreMutation::PutGeneration(generation(
|
||||||
|
DurableDecision::None,
|
||||||
|
))])
|
||||||
|
.unwrap();
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
let lock = try_exclusive_flock(lock_file(&directory.path("domain.lock"))).unwrap();
|
||||||
|
let mut other_domain = domain();
|
||||||
|
other_domain.profile_name = "other".into();
|
||||||
|
assert!(matches!(
|
||||||
|
HostCoordinatorDomainStore::open(lock, &directory.path("domain.redb"), &other_domain),
|
||||||
|
Err(DomainStoreError::DomainMismatch)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lifecycle_updates_enforce_transition_and_absence_boundaries() {
|
||||||
|
let directory = TestDirectory::new("transitions");
|
||||||
|
let store = open_store(&directory);
|
||||||
|
store
|
||||||
|
.apply(&[
|
||||||
|
DomainStoreMutation::PutGeneration(generation(DurableDecision::None)),
|
||||||
|
DomainStoreMutation::PutParticipant(publication_intent()),
|
||||||
|
DomainStoreMutation::PutSession(session(SessionPhase::New)),
|
||||||
|
DomainStoreMutation::PutMailbox(mailbox(ParticipantState::Intent)),
|
||||||
|
DomainStoreMutation::PutSessionTerminal(bare_terminal(SessionTerminalState::Intent)),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.apply(&[DomainStoreMutation::PutParticipant(publication_partial())])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
store.apply(&[DomainStoreMutation::PutSession(session(
|
||||||
|
SessionPhase::Attached
|
||||||
|
))]),
|
||||||
|
Err(DomainStoreError::InvalidRow("Session transition"))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
store.apply(&[DomainStoreMutation::PutParticipant(
|
||||||
|
Participant::snapshot(
|
||||||
|
GenerationId::new(7),
|
||||||
|
ParticipantRole::Publication,
|
||||||
|
ParticipantState::Clean,
|
||||||
|
Some(ParticipantOwnership::Publication(PublicationOwnership {
|
||||||
|
socket_path: None,
|
||||||
|
identity_path: None,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
)]),
|
||||||
|
Err(DomainStoreError::Transition(
|
||||||
|
RecordTransitionError::AbsenceProofRequired
|
||||||
|
))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
store.apply(&[DomainStoreMutation::PutMailbox(mailbox(
|
||||||
|
ParticipantState::Clean
|
||||||
|
))]),
|
||||||
|
Err(DomainStoreError::InvalidRow("Mailbox transition"))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
store.apply(&[DomainStoreMutation::PutSessionTerminal(bare_terminal(
|
||||||
|
SessionTerminalState::Clean,
|
||||||
|
))]),
|
||||||
|
Err(DomainStoreError::Transition(
|
||||||
|
RecordTransitionError::AbsenceProofRequired
|
||||||
|
))
|
||||||
|
));
|
||||||
|
|
||||||
|
store
|
||||||
|
.apply(&[DomainStoreMutation::CleanSessionTerminalAfterAbsence(
|
||||||
|
bare_terminal(SessionTerminalState::Clean),
|
||||||
|
)])
|
||||||
|
.unwrap();
|
||||||
|
let snapshot = store.snapshot().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.session_terminals[0].state(),
|
||||||
|
SessionTerminalState::Clean
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_table_is_created_in_the_first_version_document() {
|
||||||
|
let directory = TestDirectory::new("tables");
|
||||||
|
drop(open_store(&directory));
|
||||||
|
let database = Database::open(directory.path("domain.redb")).unwrap();
|
||||||
|
let read = database.begin_read().unwrap();
|
||||||
|
for name in [
|
||||||
|
METADATA.name(),
|
||||||
|
DOMAINS.name(),
|
||||||
|
GENERATIONS.name(),
|
||||||
|
PARTICIPANTS.name(),
|
||||||
|
SESSIONS.name(),
|
||||||
|
SESSION_TERMINALS.name(),
|
||||||
|
MAILBOXES.name(),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
read.list_tables()
|
||||||
|
.unwrap()
|
||||||
|
.any(|handle| handle.name() == name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(read.open_table(FOREIGN).is_err());
|
||||||
|
}
|
||||||
3
crates/lazaret-host-coordinator/src/lib.rs
Normal file
3
crates/lazaret-host-coordinator/src/lib.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
#![doc = "Host-side coordination authority for one Lazaret Domain."]
|
||||||
|
|
||||||
|
pub mod domain_store;
|
||||||
|
|
@ -386,8 +386,8 @@ classDiagram
|
||||||
`durable_decision` and `session_phase` contain values defined by the application
|
`durable_decision` and `session_phase` contain values defined by the application
|
||||||
protocol model. This schema records them without redefining either state machine.
|
protocol model. This schema records them without redefining either state machine.
|
||||||
Each store carries its own schema identity and version. The current Domain Store
|
Each store carries its own schema identity and version. The current Domain Store
|
||||||
identity is `lazaret-domain-store-v2` with version `2`. Domain Store v1 metadata
|
identity is `lazaret-domain-store-v1` with version `1`. Unknown, unversioned,
|
||||||
dispatches only to legacy migration handling; it is never decoded as v2. The
|
older, and newer metadata refuses before any row is interpreted. The
|
||||||
Storage Catalog remains `lazaret-storage-catalog-v1` with version `1`.
|
Storage Catalog remains `lazaret-storage-catalog-v1` with version `1`.
|
||||||
|
|
||||||
### Host process identity and staged ownership
|
### Host process identity and staged ownership
|
||||||
|
|
@ -407,17 +407,9 @@ classDiagram
|
||||||
start_time_ticks
|
start_time_ticks
|
||||||
}
|
}
|
||||||
|
|
||||||
class LegacyUnverifiedProcessIdentityV1 {
|
|
||||||
<<serialized>>
|
|
||||||
host_pid
|
|
||||||
start_time_ticks
|
|
||||||
}
|
|
||||||
|
|
||||||
class HostProcessIdentity {
|
class HostProcessIdentity {
|
||||||
<<serialized>>
|
<<serialized>>
|
||||||
<<union>>
|
identity
|
||||||
PidfsV1
|
|
||||||
LegacyUnverifiedV1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class PersistedHostProcessOwnership {
|
class PersistedHostProcessOwnership {
|
||||||
|
|
@ -467,8 +459,7 @@ classDiagram
|
||||||
}
|
}
|
||||||
|
|
||||||
PidfsProcessIdentityV1 --> PidfsProcessAuthorityV1 : authority
|
PidfsProcessIdentityV1 --> PidfsProcessAuthorityV1 : authority
|
||||||
HostProcessIdentity --> PidfsProcessIdentityV1 : PidfsV1
|
HostProcessIdentity --> PidfsProcessIdentityV1 : current authority
|
||||||
HostProcessIdentity --> LegacyUnverifiedProcessIdentityV1 : LegacyUnverifiedV1
|
|
||||||
PersistedHostProcessOwnership --> PidfsProcessIdentityV1 : Staged
|
PersistedHostProcessOwnership --> PidfsProcessIdentityV1 : Staged
|
||||||
PersistedHostProcessOwnership --> HostProcessIdentity : Committed
|
PersistedHostProcessOwnership --> HostProcessIdentity : Committed
|
||||||
StagedProcessOwnership --> ProcessOwnershipMessageVersion
|
StagedProcessOwnership --> ProcessOwnershipMessageVersion
|
||||||
|
|
@ -490,11 +481,8 @@ Every persisted host-process edge uses `PersistedHostProcessOwnership`.
|
||||||
contains only a current pidfs identity. It is positive authority for exact
|
contains only a current pidfs identity. It is positive authority for exact
|
||||||
same-handle cleanup and `PidfsRecoveryMatch`, including bootstrap recovery, but
|
same-handle cleanup and `PidfsRecoveryMatch`, including bootstrap recovery, but
|
||||||
cannot satisfy readiness, publication, dependent-resource creation, or
|
cannot satisfy readiness, publication, dependent-resource creation, or
|
||||||
adoption. `Committed` may contain a current pidfs identity or a
|
adoption. `Committed` contains the current pidfs identity and is the only
|
||||||
legacy-unverified v1 PID/start-only value. Only committed current pidfs
|
serviceable process-ownership phase.
|
||||||
ownership is serviceable; the legacy variant always refuses positive process
|
|
||||||
action. There is no observation-based conversion from legacy data to
|
|
||||||
`PidfsProcessIdentityV1`.
|
|
||||||
|
|
||||||
Participant `Intent` and `ReleaseIntent` may retain any process-slot phase.
|
Participant `Intent` and `ReleaseIntent` may retain any process-slot phase.
|
||||||
Every active participant state and Session Terminal `Running` requires current
|
Every active participant state and Session Terminal `Running` requires current
|
||||||
|
|
|
||||||
|
|
@ -406,9 +406,8 @@ broker performs this sequence:
|
||||||
|
|
||||||
Every durable host-process slot is exactly `Unassigned`,
|
Every durable host-process slot is exactly `Unassigned`,
|
||||||
`Staged(PidfsProcessIdentityV1)`, or `Committed(HostProcessIdentity)`. Only a
|
`Staged(PidfsProcessIdentityV1)`, or `Committed(HostProcessIdentity)`. Only a
|
||||||
current pidfs identity may be staged. A committed legacy-unverified identity is
|
current pidfs identity may be staged or committed. Current staged and committed
|
||||||
retained only for migration and refusal. Current staged and committed identity
|
identity both authorize exact matched cleanup; only committed ownership may
|
||||||
both authorize exact matched cleanup; only committed current ownership may
|
|
||||||
satisfy an active role, readiness, publication, or adoption.
|
satisfy an active role, readiness, publication, or adoption.
|
||||||
|
|
||||||
A role-bound Participant is first committed as bare `Intent` with no ownership
|
A role-bound Participant is first committed as bare `Intent` with no ownership
|
||||||
|
|
@ -419,8 +418,8 @@ Within Intent, optional fields move only absent-to-present. A newly acquired
|
||||||
child slot moves only `Unassigned` to `Staged` to the exact same current
|
child slot moves only `Unassigned` to `Staged` to the exact same current
|
||||||
identity in `Committed`. An already committed current owner or supervising
|
identity in `Committed`. An already committed current owner or supervising
|
||||||
process may enter a newly created dependent-role snapshot directly, but that
|
process may enter a newly created dependent-role snapshot directly, but that
|
||||||
recorded identity then remains immutable. Legacy data cannot use this
|
recorded identity then remains immutable. Recorded evidence cannot be replaced
|
||||||
existing-process rule. Recorded evidence cannot be replaced or cleared.
|
or cleared.
|
||||||
The direct existing-process edges are Network RS, Helper RS, Sandbox HC, and
|
The direct existing-process edges are Network RS, Helper RS, Sandbox HC, and
|
||||||
Session Terminal HC. State Anchor daemon, Git-routing daemon, Helper process,
|
Session Terminal HC. State Anchor daemon, Git-routing daemon, Helper process,
|
||||||
Sandbox RS, attached runsc, and Session Terminal exec client are acquired child
|
Sandbox RS, attached runsc, and Session Terminal exec client are acquired child
|
||||||
|
|
@ -436,7 +435,7 @@ The active Participant pairs are `prepared_lower=LeaseHeld`,
|
||||||
role-required physical evidence and current committed pidfs ownership in every
|
role-required physical evidence and current committed pidfs ownership in every
|
||||||
required process slot. `SessionTerminal=Running` likewise requires its
|
required process slot. `SessionTerminal=Running` likewise requires its
|
||||||
container, PTY, guest identity, and current committed HC and exec-client slots.
|
container, PTY, guest identity, and current committed HC and exec-client slots.
|
||||||
Staged, unassigned, or legacy process ownership cannot satisfy either boundary.
|
Staged or unassigned process ownership cannot satisfy either boundary.
|
||||||
Network readiness evidence must also name the enclosing generation and match
|
Network readiness evidence must also name the enclosing generation and match
|
||||||
its policy, fingerprint, namespace, and committed RS identity internally.
|
its policy, fingerprint, namespace, and committed RS identity internally.
|
||||||
|
|
||||||
|
|
@ -474,13 +473,6 @@ timing assumptions.
|
||||||
7. Use that same matched pidfd for every action. An action-time `ESRCH` or
|
7. Use that same matched pidfd for every action. An action-time `ESRCH` or
|
||||||
observation failure refuses without numeric fallback.
|
observation failure refuses without numeric fallback.
|
||||||
|
|
||||||
Domain Store v1 PID/start-only values dispatch to
|
|
||||||
`LegacyUnverifiedProcessIdentityV1`. They cannot be observed, converted, or
|
|
||||||
infallibly migrated into positive authority. They refuse live process action
|
|
||||||
until reboot, independently proven absence, or explicit operator resolution
|
|
||||||
allows the ordinary recovery state machine to reconcile the surrounding record.
|
|
||||||
Boot mismatch fences all old pidfs authority before PID lookup.
|
|
||||||
|
|
||||||
The contract accepts two residual assumptions: Linux boot-ID collision is
|
The contract accepts two residual assumptions: Linux boot-ID collision is
|
||||||
operationally negligible but not mathematically impossible, and the Linux 6.9+
|
operationally negligible but not mathematically impossible, and the Linux 6.9+
|
||||||
boot-monotonic 64-bit pidfs inode counter does not wrap during one boot. Codecs
|
boot-monotonic 64-bit pidfs inode counter does not wrap during one boot. Codecs
|
||||||
|
|
@ -802,8 +794,8 @@ document, never a half-applied generation decision.
|
||||||
|
|
||||||
Protocol durable decision vocabulary, multi-role intent, and mismatch refuse are
|
Protocol durable decision vocabulary, multi-role intent, and mismatch refuse are
|
||||||
defined by the protocol model. The product store encodes that generation document
|
defined by the protocol model. The product store encodes that generation document
|
||||||
in typed tables under a schema/db version (unknown newer versions fail closed
|
in typed tables under a schema/db version (unknown versions fail closed before
|
||||||
until an explicit Host Coordinator migration), including generation identity,
|
row interpretation), including generation identity,
|
||||||
minted runtime component IDs, and participant ownership material (for example
|
minted runtime component IDs, and participant ownership material (for example
|
||||||
prepared-artifact ID, staged or committed pidfs process ownership, mount
|
prepared-artifact ID, staged or committed pidfs process ownership, mount
|
||||||
identity, and network readiness fingerprint).
|
identity, and network readiness fingerprint).
|
||||||
|
|
@ -880,11 +872,10 @@ bootstrap recovery may advance it to release intent. These rules make every
|
||||||
interrupted release conservative and prevent a mounted State Anchor from
|
interrupted release conservative and prevent a mounted State Anchor from
|
||||||
becoming unleased.
|
becoming unleased.
|
||||||
|
|
||||||
Store version acceptance occurs before physical resource inspection. The
|
Store version acceptance occurs before row interpretation or physical resource
|
||||||
Domain Store v1-to-v2 migration preserves every PID/start-only value as
|
inspection. The first product Domain Store identity is
|
||||||
legacy-unverified and never observes it into pidfs authority. Migration steps
|
`lazaret-domain-store-v1` at version `1`; unversioned, unknown, older, and newer
|
||||||
create and validate destination rows in one redb transaction and update the
|
metadata refuses without fallback.
|
||||||
version last. Future versions follow the same fail-closed ordering.
|
|
||||||
|
|
||||||
Files not represented in the upper layer follow the selected base image. Files,
|
Files not represented in the upper layer follow the selected base image. Files,
|
||||||
directories, and whiteouts recorded in the upper layer remain authoritative when
|
directories, and whiteouts recorded in the upper layer remain authoritative when
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue