Integrate routed Git workspaces with persistent state
This commit is contained in:
parent
943c56eea8
commit
917409fcc6
20 changed files with 2363 additions and 119 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1255,6 +1255,7 @@ dependencies = [
|
|||
"getrandom 0.4.3",
|
||||
"lazaret-core",
|
||||
"lazaret-escalation-client",
|
||||
"lazaret-ignored-workspace",
|
||||
"lazaret-linux",
|
||||
"lazaret-prepared-lower",
|
||||
"lazaret-runtime-supervisor",
|
||||
|
|
@ -1321,6 +1322,7 @@ name = "lazaret-runtime-supervisor"
|
|||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"lazaret-core",
|
||||
"lazaret-ignored-workspace",
|
||||
"lazaret-linux",
|
||||
"lazaret-state-anchor",
|
||||
"oci-spec 0.10.0",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ harness = false
|
|||
ed25519-dalek.workspace = true
|
||||
getrandom.workspace = true
|
||||
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
|
||||
lazaret-ignored-workspace = { version = "=0.0.0", path = "../lazaret-ignored-workspace" }
|
||||
lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" }
|
||||
lazaret-prepared-lower = { version = "=0.0.0", path = "../lazaret-prepared-lower" }
|
||||
lazaret-runtime-supervisor = { version = "=0.0.0", path = "../lazaret-runtime-supervisor" }
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::sync::Arc;
|
|||
use std::sync::Mutex;
|
||||
|
||||
use lazaret_core::configuration::ResolvedDomainConfiguration;
|
||||
use lazaret_core::identity::{BundleDigest, ContainerId, MountIdentity};
|
||||
use lazaret_core::identity::{BundleDigest, ContainerId, HostPath, MountIdentity};
|
||||
use lazaret_core::network::{CanonicalDerivedNetworkPolicy, ReachabilityExpectation};
|
||||
use lazaret_core::records::Session;
|
||||
use lazaret_core::records::{
|
||||
|
|
@ -24,6 +24,7 @@ use crate::control::{
|
|||
SessionRegistry, StagedControlFace, TrustAnchor,
|
||||
};
|
||||
use crate::domain_store::{DomainStoreMutation, HostCoordinatorDomainStore};
|
||||
use crate::git_routing::GitRoutingCoordinator;
|
||||
use crate::lifecycle::{
|
||||
HostCoordinatorLifecycle, LifecycleAction, LifecycleAdapter, SessionAdmissionIdentity,
|
||||
SessionLifecycleAdapter,
|
||||
|
|
@ -77,6 +78,13 @@ pub struct ProductionBootstrapPlan<'a> {
|
|||
pub interactive_executor: Arc<dyn InteractiveSessionExecutor>,
|
||||
pub prompt_transport: Arc<SessionPromptTransport>,
|
||||
pub(crate) runtime_staging: &'a crate::production::RuntimeStaging,
|
||||
pub git_routing: Option<ProductionGitRouting>,
|
||||
}
|
||||
|
||||
pub struct ProductionGitRouting {
|
||||
pub mountpoint: HostPath,
|
||||
pub host_root: HostPath,
|
||||
pub isolated_root: HostPath,
|
||||
}
|
||||
|
||||
pub struct ProductionLifecycleAdapter<'a> {
|
||||
|
|
@ -88,6 +96,7 @@ pub struct ProductionLifecycleAdapter<'a> {
|
|||
awaiting_runtime: Option<AwaitingAttachedRunsc>,
|
||||
runtime: Option<CommittedSandboxRuntime>,
|
||||
state_anchor_mount: Option<MountIdentity>,
|
||||
git_routing_mount: Option<MountIdentity>,
|
||||
state_anchor_release: Option<StateAnchorReleaseAuthority>,
|
||||
state_anchor_absence: Option<StateAnchorAbsenceObservation>,
|
||||
}
|
||||
|
|
@ -184,6 +193,7 @@ impl<'a> ProductionLifecycleAdapter<'a> {
|
|||
awaiting_runtime: None,
|
||||
runtime: None,
|
||||
state_anchor_mount: None,
|
||||
git_routing_mount: None,
|
||||
state_anchor_release: None,
|
||||
state_anchor_absence: None,
|
||||
},
|
||||
|
|
@ -286,6 +296,24 @@ impl<'a> ProductionLifecycleAdapter<'a> {
|
|||
)
|
||||
.map_err(|error| failure(format!("mounting State Anchor: {error}")))?;
|
||||
self.state_anchor_mount = Some(mount);
|
||||
if let Some(routing) = &self.plan.git_routing {
|
||||
let git_routing = GitRoutingCoordinator::new(&store);
|
||||
git_routing
|
||||
.begin_intent(
|
||||
self.plan.generation_identity.generation_id,
|
||||
routing.mountpoint.clone(),
|
||||
routing.host_root.clone(),
|
||||
routing.isolated_root.clone(),
|
||||
)
|
||||
.map_err(failure)?;
|
||||
let git_mount = self
|
||||
.awaiting_runtime
|
||||
.as_mut()
|
||||
.ok_or_else(|| ProductionLifecycleError("Runtime Supervisor is absent".into()))?
|
||||
.start_git_routing(&git_routing)
|
||||
.map_err(|error| failure(format!("starting Git routing: {error}")))?;
|
||||
self.git_routing_mount = Some(git_mount);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -465,6 +493,15 @@ impl<'a> ProductionLifecycleAdapter<'a> {
|
|||
.ok_or_else(|| ProductionLifecycleError("committed runtime is absent".into()))?
|
||||
.stop_sandbox(&sandbox)
|
||||
.map_err(failure)?;
|
||||
if self.plan.git_routing.is_some() {
|
||||
let git_routing = GitRoutingCoordinator::new(&store);
|
||||
self.runtime
|
||||
.as_mut()
|
||||
.ok_or_else(|| ProductionLifecycleError("committed runtime is absent".into()))?
|
||||
.stop_git_routing(&git_routing)
|
||||
.map_err(failure)?;
|
||||
self.git_routing_mount = None;
|
||||
}
|
||||
self.plan.runtime_staging.cleanup_overlay().map_err(failure)
|
||||
}
|
||||
|
||||
|
|
|
|||
444
crates/lazaret-host-coordinator/src/git_routing.rs
Normal file
444
crates/lazaret-host-coordinator/src/git_routing.rs
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
use std::fmt;
|
||||
|
||||
use lazaret_core::identity::{GenerationId, HostPath, MountIdentity};
|
||||
use lazaret_core::records::{
|
||||
GitRoutingOwnership, HostProcessOwnershipSlot, Participant, ParticipantOwnership,
|
||||
ParticipantRole, ParticipantState, PersistedHostProcessOwnership, ProcessOwnershipCommitAck,
|
||||
StagedProcessOwnership,
|
||||
};
|
||||
|
||||
use crate::domain_store::{DomainStoreError, DomainStoreMutation, HostCoordinatorDomainStore};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum GitRoutingCoordinatorError {
|
||||
DomainStore(DomainStoreError),
|
||||
InvalidState(&'static str),
|
||||
}
|
||||
|
||||
impl fmt::Display for GitRoutingCoordinatorError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::DomainStore(error) => write!(formatter, "Domain Store failed: {error}"),
|
||||
Self::InvalidState(detail) => write!(formatter, "Git routing refused: {detail}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for GitRoutingCoordinatorError {}
|
||||
|
||||
impl From<DomainStoreError> for GitRoutingCoordinatorError {
|
||||
fn from(error: DomainStoreError) -> Self {
|
||||
Self::DomainStore(error)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GitRoutingCoordinator<'a> {
|
||||
store: &'a HostCoordinatorDomainStore,
|
||||
}
|
||||
|
||||
impl<'a> GitRoutingCoordinator<'a> {
|
||||
#[must_use]
|
||||
pub const fn new(store: &'a HostCoordinatorDomainStore) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Refuses a different existing participant or a durable transition failure.
|
||||
pub fn begin_intent(
|
||||
&self,
|
||||
generation_id: GenerationId,
|
||||
mountpoint: HostPath,
|
||||
host_root: HostPath,
|
||||
isolated_root: HostPath,
|
||||
) -> Result<(), GitRoutingCoordinatorError> {
|
||||
let intent = participant(
|
||||
generation_id,
|
||||
ParticipantState::Intent,
|
||||
GitRoutingOwnership {
|
||||
mountpoint: Some(mountpoint),
|
||||
host_root: Some(host_root),
|
||||
isolated_root: Some(isolated_root),
|
||||
daemon: PersistedHostProcessOwnership::unassigned(),
|
||||
mount: None,
|
||||
},
|
||||
)?;
|
||||
match self.participant(generation_id)? {
|
||||
None => self.store.apply(&[
|
||||
DomainStoreMutation::PutParticipant(Participant::bare_intent(
|
||||
generation_id,
|
||||
ParticipantRole::GitRouting,
|
||||
)),
|
||||
DomainStoreMutation::PutParticipant(intent),
|
||||
])?,
|
||||
Some(current) if current == intent => {}
|
||||
Some(_) => {
|
||||
return Err(GitRoutingCoordinatorError::InvalidState(
|
||||
"Git-routing intent already differs",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Refuses the wrong role tuple, changed ownership, or a non-Intent participant.
|
||||
pub fn stage_daemon(
|
||||
&self,
|
||||
staged: StagedProcessOwnership,
|
||||
) -> Result<(), GitRoutingCoordinatorError> {
|
||||
self.require_staged(staged)?;
|
||||
let current = self.participant(staged.generation_id())?.ok_or(
|
||||
GitRoutingCoordinatorError::InvalidState("Git-routing participant is absent"),
|
||||
)?;
|
||||
let Some(ParticipantOwnership::GitRouting(mut ownership)) = current.ownership().cloned()
|
||||
else {
|
||||
return Err(GitRoutingCoordinatorError::InvalidState(
|
||||
"Git-routing ownership is invalid",
|
||||
));
|
||||
};
|
||||
let expected = PersistedHostProcessOwnership::staged(staged.identity());
|
||||
if current.state() != ParticipantState::Intent || ownership.mount.is_some() {
|
||||
return Err(GitRoutingCoordinatorError::InvalidState(
|
||||
"Git-routing daemon cannot be staged in this phase",
|
||||
));
|
||||
}
|
||||
if ownership.daemon == expected {
|
||||
return Ok(());
|
||||
}
|
||||
if !ownership.daemon.is_unassigned() {
|
||||
return Err(GitRoutingCoordinatorError::InvalidState(
|
||||
"Git-routing daemon ownership changed",
|
||||
));
|
||||
}
|
||||
ownership.daemon = expected;
|
||||
self.put(staged.generation_id(), ParticipantState::Intent, ownership)
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Refuses an identity that is not the exact durable staged daemon.
|
||||
pub fn acknowledge_daemon(
|
||||
&self,
|
||||
staged: StagedProcessOwnership,
|
||||
) -> Result<ProcessOwnershipCommitAck, GitRoutingCoordinatorError> {
|
||||
self.require_staged(staged)?;
|
||||
let ownership = self.ownership(staged.generation_id())?;
|
||||
if ownership.daemon != PersistedHostProcessOwnership::staged(staged.identity()) {
|
||||
return Err(GitRoutingCoordinatorError::InvalidState(
|
||||
"staged Git-routing daemon is not durable",
|
||||
));
|
||||
}
|
||||
Ok(ProcessOwnershipCommitAck::v1(staged))
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Refuses changed staged ownership, missing paths, or repeated mount evidence.
|
||||
pub fn commit_ready(
|
||||
&self,
|
||||
staged: StagedProcessOwnership,
|
||||
mount: MountIdentity,
|
||||
) -> Result<(), GitRoutingCoordinatorError> {
|
||||
self.require_staged(staged)?;
|
||||
let mut ownership = self.ownership(staged.generation_id())?;
|
||||
if ownership.daemon != PersistedHostProcessOwnership::staged(staged.identity())
|
||||
|| ownership.mount.is_some()
|
||||
|| ownership.mountpoint.is_none()
|
||||
|| ownership.host_root.is_none()
|
||||
|| ownership.isolated_root.is_none()
|
||||
{
|
||||
return Err(GitRoutingCoordinatorError::InvalidState(
|
||||
"Git-routing readiness evidence is incomplete",
|
||||
));
|
||||
}
|
||||
ownership.daemon = PersistedHostProcessOwnership::committed(staged.identity());
|
||||
ownership.mount = Some(mount);
|
||||
self.put(
|
||||
staged.generation_id(),
|
||||
ParticipantState::Prepared,
|
||||
ownership,
|
||||
)
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Refuses a participant that is not Prepared.
|
||||
pub fn begin_release(
|
||||
&self,
|
||||
generation_id: GenerationId,
|
||||
) -> Result<MountIdentity, GitRoutingCoordinatorError> {
|
||||
let ownership = self.ownership(generation_id)?;
|
||||
let mount = ownership
|
||||
.mount
|
||||
.ok_or(GitRoutingCoordinatorError::InvalidState(
|
||||
"Git-routing mount evidence is absent",
|
||||
))?;
|
||||
let current =
|
||||
self.participant(generation_id)?
|
||||
.ok_or(GitRoutingCoordinatorError::InvalidState(
|
||||
"Git-routing participant is absent",
|
||||
))?;
|
||||
if current.state() != ParticipantState::Prepared {
|
||||
return Err(GitRoutingCoordinatorError::InvalidState(
|
||||
"Git routing is not Prepared",
|
||||
));
|
||||
}
|
||||
self.put(generation_id, ParticipantState::ReleaseIntent, ownership)?;
|
||||
Ok(mount)
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Refuses changed mount evidence or a non-release participant.
|
||||
pub fn complete_clean(
|
||||
&self,
|
||||
generation_id: GenerationId,
|
||||
stopped_mount: MountIdentity,
|
||||
) -> Result<(), GitRoutingCoordinatorError> {
|
||||
let current =
|
||||
self.participant(generation_id)?
|
||||
.ok_or(GitRoutingCoordinatorError::InvalidState(
|
||||
"Git-routing participant is absent",
|
||||
))?;
|
||||
let Some(ParticipantOwnership::GitRouting(ownership)) = current.ownership() else {
|
||||
return Err(GitRoutingCoordinatorError::InvalidState(
|
||||
"Git-routing ownership is invalid",
|
||||
));
|
||||
};
|
||||
if current.state() != ParticipantState::ReleaseIntent
|
||||
|| ownership.mount != Some(stopped_mount)
|
||||
{
|
||||
return Err(GitRoutingCoordinatorError::InvalidState(
|
||||
"Git-routing stop evidence does not match release intent",
|
||||
));
|
||||
}
|
||||
let clean = Participant::snapshot(
|
||||
generation_id,
|
||||
ParticipantRole::GitRouting,
|
||||
ParticipantState::Clean,
|
||||
Some(ParticipantOwnership::GitRouting(GitRoutingOwnership {
|
||||
mountpoint: None,
|
||||
host_root: None,
|
||||
isolated_root: None,
|
||||
daemon: PersistedHostProcessOwnership::unassigned(),
|
||||
mount: None,
|
||||
})),
|
||||
)
|
||||
.map_err(|_| GitRoutingCoordinatorError::InvalidState("Clean snapshot is invalid"))?;
|
||||
self.store
|
||||
.apply(&[DomainStoreMutation::CleanParticipantAfterAbsence(clean)])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_staged(
|
||||
&self,
|
||||
staged: StagedProcessOwnership,
|
||||
) -> Result<(), GitRoutingCoordinatorError> {
|
||||
if staged.domain_id() != self.store.snapshot()?.domain.domain_id
|
||||
|| staged.slot() != HostProcessOwnershipSlot::GitRoutingDaemon
|
||||
{
|
||||
return Err(GitRoutingCoordinatorError::InvalidState(
|
||||
"Git-routing staged identity has the wrong tuple",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ownership(
|
||||
&self,
|
||||
generation_id: GenerationId,
|
||||
) -> Result<GitRoutingOwnership, GitRoutingCoordinatorError> {
|
||||
self.participant(generation_id)?
|
||||
.and_then(|participant| match participant.ownership().cloned() {
|
||||
Some(ParticipantOwnership::GitRouting(ownership)) => Some(ownership),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or(GitRoutingCoordinatorError::InvalidState(
|
||||
"Git-routing ownership is absent",
|
||||
))
|
||||
}
|
||||
|
||||
fn participant(
|
||||
&self,
|
||||
generation_id: GenerationId,
|
||||
) -> Result<Option<Participant>, GitRoutingCoordinatorError> {
|
||||
Ok(self
|
||||
.store
|
||||
.snapshot()?
|
||||
.participants
|
||||
.into_iter()
|
||||
.find(|participant| {
|
||||
participant.generation_id() == generation_id
|
||||
&& participant.role() == ParticipantRole::GitRouting
|
||||
}))
|
||||
}
|
||||
|
||||
fn put(
|
||||
&self,
|
||||
generation_id: GenerationId,
|
||||
state: ParticipantState,
|
||||
ownership: GitRoutingOwnership,
|
||||
) -> Result<(), GitRoutingCoordinatorError> {
|
||||
self.store
|
||||
.apply(&[DomainStoreMutation::PutParticipant(participant(
|
||||
generation_id,
|
||||
state,
|
||||
ownership,
|
||||
)?)])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn participant(
|
||||
generation_id: GenerationId,
|
||||
state: ParticipantState,
|
||||
ownership: GitRoutingOwnership,
|
||||
) -> Result<Participant, GitRoutingCoordinatorError> {
|
||||
Participant::snapshot(
|
||||
generation_id,
|
||||
ParticipantRole::GitRouting,
|
||||
state,
|
||||
Some(ParticipantOwnership::GitRouting(ownership)),
|
||||
)
|
||||
.map_err(|_| GitRoutingCoordinatorError::InvalidState("participant snapshot is invalid"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs::OpenOptions;
|
||||
|
||||
use lazaret_core::identity::{
|
||||
BootId, ConfigurationFingerprint, CoordinatorId, DeviceId, DomainId, HostPidLocator,
|
||||
InodeId, MountId, NetworkSidecarId, PidfsDeviceId, PidfsInodeId, PidfsProcessAuthorityV1,
|
||||
PidfsProcessIdentityV1, ProcessStartTimeTicks, SandboxId,
|
||||
};
|
||||
use lazaret_core::records::{Domain, DurableDecision, Generation, HostProcessOwnershipSlot};
|
||||
use lazaret_linux::filesystem::try_exclusive_flock;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_router_lifecycle_reaches_clean_only_after_matching_stop() {
|
||||
let fixture = Fixture::new();
|
||||
let coordinator = GitRoutingCoordinator::new(&fixture.store);
|
||||
coordinator
|
||||
.begin_intent(
|
||||
GenerationId::new(4),
|
||||
HostPath::new(b"/runtime/workspace".to_vec()),
|
||||
HostPath::new(b"/host/workspace".to_vec()),
|
||||
HostPath::new(b"/state/ignored".to_vec()),
|
||||
)
|
||||
.unwrap();
|
||||
let staged = staged(HostProcessOwnershipSlot::GitRoutingDaemon);
|
||||
coordinator.stage_daemon(staged).unwrap();
|
||||
assert!(
|
||||
coordinator
|
||||
.acknowledge_daemon(staged)
|
||||
.unwrap()
|
||||
.acknowledges(&staged)
|
||||
);
|
||||
let expected_mount = mount(21);
|
||||
coordinator.commit_ready(staged, expected_mount).unwrap();
|
||||
assert_eq!(
|
||||
coordinator.begin_release(GenerationId::new(4)).unwrap(),
|
||||
expected_mount
|
||||
);
|
||||
assert!(
|
||||
coordinator
|
||||
.complete_clean(GenerationId::new(4), mount(22))
|
||||
.is_err()
|
||||
);
|
||||
coordinator
|
||||
.complete_clean(GenerationId::new(4), expected_mount)
|
||||
.unwrap();
|
||||
let participant = fixture
|
||||
.store
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.participants
|
||||
.into_iter()
|
||||
.find(|participant| participant.role() == ParticipantRole::GitRouting)
|
||||
.unwrap();
|
||||
assert_eq!(participant.state(), ParticipantState::Clean);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_process_role_cannot_enter_router_ownership() {
|
||||
let fixture = Fixture::new();
|
||||
let coordinator = GitRoutingCoordinator::new(&fixture.store);
|
||||
coordinator
|
||||
.begin_intent(
|
||||
GenerationId::new(4),
|
||||
HostPath::new(b"/runtime/workspace".to_vec()),
|
||||
HostPath::new(b"/host/workspace".to_vec()),
|
||||
HostPath::new(b"/state/ignored".to_vec()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
coordinator
|
||||
.stage_daemon(staged(HostProcessOwnershipSlot::SandboxRuntimeSupervisor))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
_tree: tempfile::TempDir,
|
||||
store: HostCoordinatorDomainStore,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let tree = tempfile::tempdir().unwrap();
|
||||
let lock = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(tree.path().join("domain.lock"))
|
||||
.unwrap();
|
||||
let store = HostCoordinatorDomainStore::open(
|
||||
try_exclusive_flock(lock).unwrap(),
|
||||
&tree.path().join("domain.redb"),
|
||||
&Domain {
|
||||
domain_id: DomainId::new([1; 32]),
|
||||
profile_name: "default".into(),
|
||||
canonical_workspace: HostPath::new(b"/host/workspace".to_vec()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
store
|
||||
.apply(&[DomainStoreMutation::PutGeneration(Generation {
|
||||
domain_id: DomainId::new([1; 32]),
|
||||
generation_id: GenerationId::new(4),
|
||||
configuration_fingerprint: ConfigurationFingerprint::new([9; 32]),
|
||||
coordinator_id: CoordinatorId::new([2; 32]),
|
||||
network_sidecar_id: NetworkSidecarId::new([10; 32]),
|
||||
sandbox_id: SandboxId::new([11; 32]),
|
||||
durable_decision: DurableDecision::None,
|
||||
})])
|
||||
.unwrap();
|
||||
Self { _tree: tree, store }
|
||||
}
|
||||
}
|
||||
|
||||
fn staged(slot: HostProcessOwnershipSlot) -> StagedProcessOwnership {
|
||||
StagedProcessOwnership::v1(
|
||||
DomainId::new([1; 32]),
|
||||
GenerationId::new(4),
|
||||
CoordinatorId::new([2; 32]),
|
||||
slot,
|
||||
PidfsProcessIdentityV1::new(
|
||||
PidfsProcessAuthorityV1 {
|
||||
boot_id: BootId::new([3; 16]),
|
||||
pidfs_st_dev: PidfsDeviceId::new(5),
|
||||
pidfs_st_ino: PidfsInodeId::new(6),
|
||||
},
|
||||
HostPidLocator::new(7),
|
||||
ProcessStartTimeTicks::new(8),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn mount(value: u64) -> MountIdentity {
|
||||
MountIdentity {
|
||||
mount_id: MountId::new(value),
|
||||
device: DeviceId::new(value + 1),
|
||||
inode: InodeId::new(value + 2),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ pub mod client_bootstrap;
|
|||
pub mod control;
|
||||
pub mod domain_store;
|
||||
pub mod escalation;
|
||||
pub mod git_routing;
|
||||
pub mod lifecycle;
|
||||
pub mod network;
|
||||
pub mod production;
|
||||
|
|
|
|||
|
|
@ -32,6 +32,13 @@ fn main() {
|
|||
}
|
||||
return;
|
||||
}
|
||||
if let Some(result) = lazaret_ignored_workspace::dispatch_private_router(&arguments) {
|
||||
if let Err(error) = result {
|
||||
eprintln!("ignored workspace router failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(result) =
|
||||
lazaret_runtime_supervisor::supervision::dispatch_private_runtime_role(&arguments)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use std::cell::RefCell;
|
|||
use std::ffi::OsString;
|
||||
use std::fmt;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::io::{Read, Write};
|
||||
use std::os::fd::{AsFd, OwnedFd};
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
use std::os::unix::fs::{FileExt, PermissionsExt};
|
||||
|
|
@ -10,18 +10,25 @@ use std::path::{Path, PathBuf};
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use lazaret_core::configuration::{ConfinedPathPlan, ResolvedDomainConfiguration};
|
||||
use lazaret_core::identity::{BundleDigest, ContainerId, MountIdentity};
|
||||
use lazaret_core::identity::{BundleDigest, ContainerId, HostPath, MountIdentity};
|
||||
use lazaret_core::network::{ReachabilityExpectation, derive_ipv4_policy};
|
||||
use lazaret_core::records::{
|
||||
Generation, GenerationIdentity, HostProcessOwnershipSlot, IpAddress, PreparedArtifact, Session,
|
||||
};
|
||||
use lazaret_ignored_workspace::git_common_directory;
|
||||
use lazaret_linux::filesystem::{mount_identity, namespace_identity};
|
||||
use lazaret_linux::process::{ProcessOwnershipContext, current_process_identity};
|
||||
use lazaret_linux::process::{
|
||||
ProcessOwnershipContext, TargetCommand, child_broker, current_process_identity,
|
||||
};
|
||||
use lazaret_runtime_supervisor::network::NetworkProbeRequest;
|
||||
use lazaret_runtime_supervisor::sandbox::{
|
||||
RootProcess, SandboxBuildRequest, SandboxBundle, build_sandbox_bundle,
|
||||
build_sandbox_bundle_with_workspace,
|
||||
};
|
||||
use lazaret_runtime_supervisor::supervision::{
|
||||
RuntimeGitRoutingAuthority, RuntimeGitRoutingLaunch, RuntimeNetworkLaunch,
|
||||
RuntimeSupervisorLaunch,
|
||||
};
|
||||
use lazaret_runtime_supervisor::supervision::{RuntimeNetworkLaunch, RuntimeSupervisorLaunch};
|
||||
use lazaret_state_anchor::{StateAnchorLaunchAuthority, StateAnchorLayout};
|
||||
use lazaret_storage_catalog::Catalog;
|
||||
use rustix::event::{PollFd, PollFlags, Timespec, poll};
|
||||
|
|
@ -30,6 +37,7 @@ use rustix::fs::{
|
|||
unlinkat,
|
||||
};
|
||||
use rustix::io::{FdFlags, dup, fcntl_setfd};
|
||||
use rustix::pipe::{PipeFlags, pipe_with};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::bootstrap::{
|
||||
|
|
@ -141,6 +149,7 @@ pub struct ProductionColdStart<'a> {
|
|||
bundle_digest: BundleDigest,
|
||||
invoking_uid: u32,
|
||||
staging: RuntimeStaging,
|
||||
git_routing: Option<GitRoutingLayout>,
|
||||
}
|
||||
|
||||
impl<'a> ProductionColdStart<'a> {
|
||||
|
|
@ -149,6 +158,7 @@ impl<'a> ProductionColdStart<'a> {
|
|||
/// # Errors
|
||||
/// Refuses mismatched generation/configuration/probe inputs, changed host authority, unsafe
|
||||
/// staging paths, or component construction failure.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn stage(inputs: ProductionColdStartInputs<'a>) -> Result<Self, ProductionColdStartError> {
|
||||
require_inputs(&inputs)?;
|
||||
let runtime_domain = host_path(
|
||||
|
|
@ -168,16 +178,28 @@ impl<'a> ProductionColdStart<'a> {
|
|||
inputs.invoking_uid,
|
||||
)
|
||||
.map_err(|error| failure(format!("runtime staging: {error}")))?;
|
||||
let git_routing = GitRoutingLayout::create(
|
||||
inputs.configuration,
|
||||
inputs.container_id,
|
||||
inputs.invoking_uid,
|
||||
)
|
||||
.map_err(|error| failure(format!("Git-routing staging: {error}")))?;
|
||||
let expected_network_namespace = namespace_identity(Path::new(NETWORK_NAMESPACE_PATH))
|
||||
.map_err(|error| failure(format!("network namespace: {error}")))?;
|
||||
let sandbox_bundle = build_sandbox_bundle(SandboxBuildRequest {
|
||||
let request = SandboxBuildRequest {
|
||||
configuration: inputs.configuration,
|
||||
container_id: inputs.container_id,
|
||||
expected_network_namespace,
|
||||
root_process: inputs.root_process,
|
||||
terminal: None,
|
||||
prepared_root: inputs.prepared_artifact_label.to_owned(),
|
||||
})
|
||||
};
|
||||
let sandbox_bundle = match &git_routing {
|
||||
Some(git_routing) => {
|
||||
build_sandbox_bundle_with_workspace(request, git_routing.mountpoint_label.clone())
|
||||
}
|
||||
None => build_sandbox_bundle(request),
|
||||
}
|
||||
.map_err(|error| failure(format!("sandbox bundle: {error}")))?;
|
||||
staging
|
||||
.write_bundle(sandbox_bundle.specification_json())
|
||||
|
|
@ -197,7 +219,7 @@ impl<'a> ProductionColdStart<'a> {
|
|||
inputs.prepared_artifact_authority,
|
||||
)
|
||||
.map_err(|error| failure(format!("State Anchor authority: {error}")))?;
|
||||
let runtime_launch = RuntimeSupervisorLaunch::from_networked_runsc_command(
|
||||
let mut runtime_launch = RuntimeSupervisorLaunch::from_networked_runsc_command(
|
||||
ownership,
|
||||
sandbox_bundle.command(),
|
||||
RuntimeNetworkLaunch {
|
||||
|
|
@ -208,6 +230,13 @@ impl<'a> ProductionColdStart<'a> {
|
|||
)
|
||||
.map_err(|error| failure(format!("Runtime Supervisor launch: {error}")))?
|
||||
.with_state_anchor(state_anchor);
|
||||
if let Some(git_routing) = &git_routing {
|
||||
runtime_launch = runtime_launch.with_git_routing(
|
||||
git_routing
|
||||
.launch_authority(inputs.configuration)
|
||||
.map_err(|error| failure(format!("Git-routing authority: {error}")))?,
|
||||
);
|
||||
}
|
||||
let bundle_digest =
|
||||
BundleDigest::new(Sha256::digest(sandbox_bundle.specification_json()).into());
|
||||
let generation_identity = GenerationIdentity {
|
||||
|
|
@ -236,6 +265,7 @@ impl<'a> ProductionColdStart<'a> {
|
|||
bundle_digest,
|
||||
invoking_uid: inputs.invoking_uid,
|
||||
staging,
|
||||
git_routing,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -363,6 +393,13 @@ impl<'a> ProductionColdStart<'a> {
|
|||
interactive_executor: Arc::new(interactive_executor),
|
||||
prompt_transport,
|
||||
runtime_staging: &self.staging,
|
||||
git_routing: self.git_routing.as_ref().map(|routing| {
|
||||
crate::bootstrap::ProductionGitRouting {
|
||||
mountpoint: routing.mountpoint(),
|
||||
host_root: routing.host_root(),
|
||||
isolated_root: routing.isolated_root(),
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -383,6 +420,253 @@ fn require_inputs(inputs: &ProductionColdStartInputs<'_>) -> Result<(), Producti
|
|||
Ok(())
|
||||
}
|
||||
|
||||
struct GitRoutingLayout {
|
||||
_runtime_directory: RuntimeGitRoutingDirectory,
|
||||
workspace: File,
|
||||
workspace_label: PathBuf,
|
||||
isolated: File,
|
||||
isolated_label: PathBuf,
|
||||
mountpoint: File,
|
||||
mountpoint_label: PathBuf,
|
||||
git_common: File,
|
||||
git_common_label: PathBuf,
|
||||
transient_hooks: File,
|
||||
transient_hooks_label: PathBuf,
|
||||
transient_config: File,
|
||||
transient_config_label: PathBuf,
|
||||
global_excludes: Option<PathBuf>,
|
||||
isolate_ignored: bool,
|
||||
}
|
||||
|
||||
impl GitRoutingLayout {
|
||||
fn create(
|
||||
configuration: &ResolvedDomainConfiguration,
|
||||
container_id: ContainerId,
|
||||
invoking_uid: u32,
|
||||
) -> Result<Option<Self>, ProductionColdStartError> {
|
||||
let workspace_label = host_path(configuration.workspace().canonical_label().as_bytes());
|
||||
let workspace = File::from(
|
||||
configuration
|
||||
.workspace()
|
||||
.open_authority()
|
||||
.map_err(failure)?,
|
||||
);
|
||||
match statat(&workspace, ".git", AtFlags::SYMLINK_NOFOLLOW) {
|
||||
Ok(_) => {}
|
||||
Err(rustix::io::Errno::NOENT) => return Ok(None),
|
||||
Err(error) => return Err(failure(error)),
|
||||
}
|
||||
let persistent_root = configuration
|
||||
.persistent_domain_root()
|
||||
.open_authority()
|
||||
.map_err(failure)?;
|
||||
let isolated = open_or_create_private_directory(
|
||||
&persistent_root,
|
||||
"ignored-workspace",
|
||||
invoking_uid,
|
||||
false,
|
||||
)?;
|
||||
let runtime_root = configuration
|
||||
.runtime_domain_root()
|
||||
.open_authority()
|
||||
.map_err(failure)?;
|
||||
let runtime_parent =
|
||||
open_or_create_private_directory(&runtime_root, "git-routing", invoking_uid, false)?;
|
||||
let runtime_name = OsString::from(hex(container_id.as_bytes()));
|
||||
let directory =
|
||||
open_or_create_private_directory(&runtime_parent, &runtime_name, invoking_uid, true)?;
|
||||
let identity = FileIdentity::capture(&directory)?;
|
||||
let runtime_directory = RuntimeGitRoutingDirectory {
|
||||
parent: runtime_parent,
|
||||
directory,
|
||||
name: runtime_name.clone(),
|
||||
identity,
|
||||
};
|
||||
let mountpoint = open_or_create_private_directory(
|
||||
&runtime_directory.directory,
|
||||
"workspace",
|
||||
invoking_uid,
|
||||
true,
|
||||
)?;
|
||||
let transient_hooks = open_or_create_private_directory(
|
||||
&runtime_directory.directory,
|
||||
"hooks",
|
||||
invoking_uid,
|
||||
true,
|
||||
)?;
|
||||
let transient_config = openat2(
|
||||
&runtime_directory.directory,
|
||||
"config",
|
||||
OFlags::RDWR | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::RUSR | Mode::WUSR,
|
||||
confined_resolve_flags(),
|
||||
)
|
||||
.map_err(failure)?;
|
||||
fchmod(&transient_config, Mode::RUSR | Mode::WUSR).map_err(failure)?;
|
||||
require_private_file(&transient_config, invoking_uid, 0o600)?;
|
||||
let isolated_label = host_path(
|
||||
configuration
|
||||
.persistent_domain_root()
|
||||
.canonical_label()
|
||||
.as_bytes(),
|
||||
)
|
||||
.join("ignored-workspace");
|
||||
let runtime_label = host_path(
|
||||
configuration
|
||||
.runtime_domain_root()
|
||||
.canonical_label()
|
||||
.as_bytes(),
|
||||
)
|
||||
.join("git-routing")
|
||||
.join(&runtime_name);
|
||||
let mountpoint_label = runtime_label.join("workspace");
|
||||
let transient_hooks_label = runtime_label.join("hooks");
|
||||
let transient_config_label = runtime_label.join("config");
|
||||
let git_common_path = git_common_directory(&workspace_label).map_err(failure)?;
|
||||
let git_common = open_absolute_directory(&git_common_path)?;
|
||||
let global_excludes = global_excludes_path(&workspace_label)?;
|
||||
Ok(Some(Self {
|
||||
_runtime_directory: runtime_directory,
|
||||
workspace,
|
||||
workspace_label,
|
||||
isolated: File::from(isolated),
|
||||
isolated_label,
|
||||
mountpoint: File::from(mountpoint),
|
||||
mountpoint_label,
|
||||
git_common,
|
||||
git_common_label: git_common_path,
|
||||
transient_hooks: File::from(transient_hooks),
|
||||
transient_hooks_label,
|
||||
transient_config: File::from(transient_config),
|
||||
transient_config_label,
|
||||
global_excludes,
|
||||
isolate_ignored: configuration.fingerprint_record().isolate_gitignored,
|
||||
}))
|
||||
}
|
||||
|
||||
fn launch_authority(
|
||||
&self,
|
||||
configuration: &ResolvedDomainConfiguration,
|
||||
) -> Result<RuntimeGitRoutingLaunch, ProductionColdStartError> {
|
||||
configuration
|
||||
.workspace()
|
||||
.open_authority()
|
||||
.map_err(failure)?;
|
||||
RuntimeGitRoutingLaunch::new(RuntimeGitRoutingAuthority {
|
||||
workspace: self.workspace.try_clone().map_err(failure)?,
|
||||
workspace_label: self.workspace_label.clone(),
|
||||
isolated: self.isolated.try_clone().map_err(failure)?,
|
||||
mountpoint: self.mountpoint.try_clone().map_err(failure)?,
|
||||
mountpoint_label: self.mountpoint_label.clone(),
|
||||
git_common: self.git_common.try_clone().map_err(failure)?,
|
||||
git_common_label: self.git_common_label.clone(),
|
||||
transient_hooks: self.transient_hooks.try_clone().map_err(failure)?,
|
||||
transient_hooks_label: self.transient_hooks_label.clone(),
|
||||
transient_config: self.transient_config.try_clone().map_err(failure)?,
|
||||
transient_config_label: self.transient_config_label.clone(),
|
||||
global_excludes: self.global_excludes.clone(),
|
||||
isolate_ignored: self.isolate_ignored,
|
||||
})
|
||||
.map_err(failure)
|
||||
}
|
||||
|
||||
fn mountpoint(&self) -> HostPath {
|
||||
HostPath::new(
|
||||
self.mountpoint_label
|
||||
.as_os_str()
|
||||
.as_encoded_bytes()
|
||||
.to_vec(),
|
||||
)
|
||||
}
|
||||
|
||||
fn host_root(&self) -> HostPath {
|
||||
HostPath::new(self.workspace_label.as_os_str().as_encoded_bytes().to_vec())
|
||||
}
|
||||
|
||||
fn isolated_root(&self) -> HostPath {
|
||||
HostPath::new(self.isolated_label.as_os_str().as_encoded_bytes().to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
struct RuntimeGitRoutingDirectory {
|
||||
parent: OwnedFd,
|
||||
directory: OwnedFd,
|
||||
name: OsString,
|
||||
identity: FileIdentity,
|
||||
}
|
||||
|
||||
impl Drop for RuntimeGitRoutingDirectory {
|
||||
fn drop(&mut self) {
|
||||
remove_staging_directory(&self.parent, &self.name, &self.directory, self.identity);
|
||||
}
|
||||
}
|
||||
|
||||
fn open_absolute_directory(path: &Path) -> Result<File, ProductionColdStartError> {
|
||||
let descriptor = openat2(
|
||||
rustix::fs::CWD,
|
||||
path,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::empty(),
|
||||
ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_SYMLINKS,
|
||||
)
|
||||
.map_err(failure)?;
|
||||
Ok(File::from(descriptor))
|
||||
}
|
||||
|
||||
fn global_excludes_path(workspace: &Path) -> Result<Option<PathBuf>, ProductionColdStartError> {
|
||||
let (reader, writer) = pipe_with(PipeFlags::CLOEXEC).map_err(failure)?;
|
||||
let target = TargetCommand::new("git")
|
||||
.argument("-C")
|
||||
.argument(workspace)
|
||||
.argument("config")
|
||||
.argument("--path")
|
||||
.argument("--get")
|
||||
.argument("--default")
|
||||
.argument("")
|
||||
.argument("core.excludesFile");
|
||||
child_broker()
|
||||
.run_transient_with_private_stdout(&target, writer)
|
||||
.map_err(|error| failure(format!("Git global-excludes discovery: {error}")))?;
|
||||
let mut output = Vec::new();
|
||||
File::from(reader)
|
||||
.read_to_end(&mut output)
|
||||
.map_err(failure)?;
|
||||
parse_global_excludes_path(workspace, &output)
|
||||
}
|
||||
|
||||
fn parse_global_excludes_path(
|
||||
workspace: &Path,
|
||||
output: &[u8],
|
||||
) -> Result<Option<PathBuf>, ProductionColdStartError> {
|
||||
let value = output.strip_suffix(b"\n").unwrap_or(output);
|
||||
if value.contains(&0) {
|
||||
return Err(ProductionColdStartError(
|
||||
"Git global-excludes path is invalid".into(),
|
||||
));
|
||||
}
|
||||
if value.is_empty() {
|
||||
return Ok(default_global_excludes_path());
|
||||
}
|
||||
let path = PathBuf::from(OsString::from_vec(value.to_vec()));
|
||||
Ok(Some(if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
workspace.join(path)
|
||||
}))
|
||||
}
|
||||
|
||||
fn default_global_excludes_path() -> Option<PathBuf> {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
std::env::var_os("HOME")
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(|home| PathBuf::from(home).join(".config"))
|
||||
})
|
||||
.map(|home| home.join("git/ignore"))
|
||||
}
|
||||
|
||||
pub(crate) struct RuntimeStaging {
|
||||
runsc_root: OwnedFd,
|
||||
bundles: OwnedFd,
|
||||
|
|
@ -778,4 +1062,16 @@ mod tests {
|
|||
let descriptor: OwnedFd = File::open("/dev/null").unwrap().into();
|
||||
assert!(BootstrapOwnerGuard::from_inherited(descriptor).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_relative_global_excludes_are_workspace_relative() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let workspace = root.path().join("workspace");
|
||||
|
||||
assert_eq!(
|
||||
super::parse_global_excludes_path(&workspace, b"policy/ignore\n").unwrap(),
|
||||
Some(workspace.join("policy/ignore"))
|
||||
);
|
||||
assert!(super::parse_global_excludes_path(&workspace, b"bad\0path\n").is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ use rustix::pipe::{PipeFlags, pipe_with};
|
|||
use rustix::process::Signal;
|
||||
|
||||
use crate::domain_store::{DomainStoreError, DomainStoreMutation, HostCoordinatorDomainStore};
|
||||
use crate::git_routing::GitRoutingCoordinator;
|
||||
use crate::network::{NetworkCoordinator, NetworkCoordinatorError};
|
||||
|
||||
const SUPERVISOR_EXIT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
|
@ -377,6 +378,8 @@ pub struct AwaitingAttachedRunsc {
|
|||
lifetime: Option<std::os::fd::OwnedFd>,
|
||||
staged: Option<StagedProcessOwnership>,
|
||||
helper_staged: Option<StagedProcessOwnership>,
|
||||
git_routing_required: bool,
|
||||
git_routing_mount: Option<MountIdentity>,
|
||||
network_evidence: Option<NetworkReadinessEvidence>,
|
||||
runsc_command: lazaret_runtime_supervisor::sandbox::RunscCommand,
|
||||
}
|
||||
|
|
@ -453,6 +456,67 @@ impl AwaitingAttachedRunsc {
|
|||
Ok(mount)
|
||||
}
|
||||
|
||||
/// Requests generation-local routing and commits exact staged and mount evidence.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses missing intent, event mismatch, changed ownership, or channel failure.
|
||||
pub fn start_git_routing(
|
||||
&mut self,
|
||||
coordinator: &GitRoutingCoordinator<'_>,
|
||||
) -> Result<MountIdentity, SandboxCoordinatorError> {
|
||||
if !self.git_routing_required || self.git_routing_mount.is_some() {
|
||||
return Err(SandboxCoordinatorError::InvalidState(
|
||||
"Git routing is not awaiting start",
|
||||
));
|
||||
}
|
||||
let control = self
|
||||
.control
|
||||
.as_ref()
|
||||
.ok_or(SandboxCoordinatorError::InvalidState(
|
||||
"Runtime Supervisor control channel is absent",
|
||||
))?;
|
||||
control.start_git_routing()?;
|
||||
let staged = match control.receive()? {
|
||||
SupervisorEvent::GitRoutingStaged(staged) => staged,
|
||||
SupervisorEvent::Refused(detail) => {
|
||||
return Err(SandboxCoordinatorError::Io(io::Error::other(detail)));
|
||||
}
|
||||
_ => {
|
||||
return Err(SandboxCoordinatorError::InvalidState(
|
||||
"Runtime Supervisor did not stage the Git router",
|
||||
));
|
||||
}
|
||||
};
|
||||
coordinator
|
||||
.stage_daemon(staged)
|
||||
.map_err(|error| SandboxCoordinatorError::Io(io::Error::other(error)))?;
|
||||
let acknowledgment = coordinator
|
||||
.acknowledge_daemon(staged)
|
||||
.map_err(|error| SandboxCoordinatorError::Io(io::Error::other(error)))?;
|
||||
control.acknowledge_git_routing(acknowledgment)?;
|
||||
let (ready_staged, mount) = match control.receive()? {
|
||||
SupervisorEvent::GitRoutingReady { staged, mount } => (staged, mount),
|
||||
SupervisorEvent::Refused(detail) => {
|
||||
return Err(SandboxCoordinatorError::Io(io::Error::other(detail)));
|
||||
}
|
||||
_ => {
|
||||
return Err(SandboxCoordinatorError::InvalidState(
|
||||
"Runtime Supervisor did not report Git-routing readiness",
|
||||
));
|
||||
}
|
||||
};
|
||||
if ready_staged != staged {
|
||||
return Err(SandboxCoordinatorError::InvalidState(
|
||||
"Git-routing readiness changed daemon ownership",
|
||||
));
|
||||
}
|
||||
coordinator
|
||||
.commit_ready(staged, mount)
|
||||
.map_err(|error| SandboxCoordinatorError::Io(io::Error::other(error)))?;
|
||||
self.git_routing_mount = Some(mount);
|
||||
Ok(mount)
|
||||
}
|
||||
|
||||
/// Releases the RS-owned sidecar install path after exact State Anchor readiness.
|
||||
///
|
||||
/// # Errors
|
||||
|
|
@ -550,6 +614,8 @@ impl AwaitingAttachedRunsc {
|
|||
lifetime: Some(coordinator_lifetime),
|
||||
staged: None,
|
||||
helper_staged: None,
|
||||
git_routing_required: launch.has_git_routing(),
|
||||
git_routing_mount: None,
|
||||
network_evidence: None,
|
||||
runsc_command: launch.runsc_command().clone(),
|
||||
})
|
||||
|
|
@ -693,6 +759,11 @@ impl AwaitingAttachedRunsc {
|
|||
"attached runsc cannot start before network attestation",
|
||||
));
|
||||
}
|
||||
if self.git_routing_required && self.git_routing_mount.is_none() {
|
||||
return Err(SandboxCoordinatorError::InvalidState(
|
||||
"attached runsc cannot start before Git-routing readiness",
|
||||
));
|
||||
}
|
||||
let event = self
|
||||
.control
|
||||
.as_ref()
|
||||
|
|
@ -944,6 +1015,31 @@ impl CommittedSandboxRuntime {
|
|||
coordinator.complete_clean_after_absence(self.generation_id)
|
||||
}
|
||||
|
||||
/// Stops exact generation-local Git routing after sandbox absence.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses missing release intent, changed mount evidence, or an unexpected RS response.
|
||||
pub fn stop_git_routing(
|
||||
&mut self,
|
||||
coordinator: &GitRoutingCoordinator<'_>,
|
||||
) -> Result<(), SandboxCoordinatorError> {
|
||||
let expected = coordinator
|
||||
.begin_release(self.generation_id)
|
||||
.map_err(|error| SandboxCoordinatorError::Io(io::Error::other(error)))?;
|
||||
self.control.stop_git_routing()?;
|
||||
match self.control.receive()? {
|
||||
SupervisorEvent::GitRoutingStopped { mount } if mount == expected => coordinator
|
||||
.complete_clean(self.generation_id, mount)
|
||||
.map_err(|error| SandboxCoordinatorError::Io(io::Error::other(error))),
|
||||
SupervisorEvent::Refused(detail) => {
|
||||
Err(SandboxCoordinatorError::Io(io::Error::other(detail)))
|
||||
}
|
||||
_ => Err(SandboxCoordinatorError::InvalidState(
|
||||
"Runtime Supervisor did not stop exact Git routing",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops only the RS-owned network resources after sandbox absence.
|
||||
///
|
||||
/// # Errors
|
||||
|
|
|
|||
|
|
@ -41,6 +41,15 @@ fn validated_inputs_stage_one_complete_plan_and_drop_exact_runtime_artifacts() {
|
|||
assert!(!control.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_git_workspace_stages_without_a_routing_runtime() {
|
||||
let fixture = ProductionFixture::new();
|
||||
|
||||
let staged = ProductionColdStart::stage(fixture.inputs(&fixture.escalation_client)).unwrap();
|
||||
assert!(!fixture.runtime_domain.join("git-routing").exists());
|
||||
drop(staged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_leaves_a_replacement_directory_untouched() {
|
||||
let fixture = ProductionFixture::new();
|
||||
|
|
|
|||
|
|
@ -13,15 +13,16 @@ fn main() {
|
|||
|
||||
fn run() -> io::Result<()> {
|
||||
let arguments = env::args_os().skip(1).collect::<Vec<_>>();
|
||||
if arguments.len() != 3 {
|
||||
if arguments.len() != 4 {
|
||||
return Err(io::Error::other(
|
||||
"usage: lazaret-git-sentry MOUNTED_WORKSPACE HOST_GIT_COMMON_DIRECTORY TRANSIENT_HOOKS",
|
||||
"usage: lazaret-git-sentry MOUNTED_WORKSPACE HOST_GIT_COMMON_DIRECTORY TRANSIENT_HOOKS TRANSIENT_CONFIG",
|
||||
));
|
||||
}
|
||||
let sentry = GitSentry::install(&GitSentryConfiguration {
|
||||
mounted_workspace: PathBuf::from(&arguments[0]),
|
||||
host_git_common_directory: PathBuf::from(&arguments[1]),
|
||||
transient_hooks: PathBuf::from(&arguments[2]),
|
||||
transient_config: PathBuf::from(&arguments[3]),
|
||||
})?;
|
||||
println!("ready");
|
||||
io::stdout().flush()?;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,16 @@ pub struct GitClassifier {
|
|||
current: Mutex<Arc<SnapshotInner>>,
|
||||
}
|
||||
|
||||
/// Resolves the root repository's effective common Git directory.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses a non-repository workspace or malformed linked-worktree metadata.
|
||||
pub fn git_common_directory(workspace: &Path) -> io::Result<PathBuf> {
|
||||
let workspace = workspace.canonicalize()?;
|
||||
let root = BackingRoot::open(&workspace)?;
|
||||
Ok(repository_metadata(&workspace, &root, Path::new(""))?.common_dir)
|
||||
}
|
||||
|
||||
impl GitClassifier {
|
||||
/// # Errors
|
||||
/// Refuses a non-repository workspace or unreadable Git policy input.
|
||||
|
|
@ -386,6 +396,7 @@ struct RepositoryMetadata {
|
|||
index: PathBuf,
|
||||
config: PathBuf,
|
||||
info_exclude: PathBuf,
|
||||
common_dir: PathBuf,
|
||||
}
|
||||
|
||||
fn repository_metadata(
|
||||
|
|
@ -432,6 +443,7 @@ fn repository_metadata(
|
|||
index: git_dir.join("index"),
|
||||
config: common_dir.join("config"),
|
||||
info_exclude: common_dir.join("info/exclude"),
|
||||
common_dir,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
189
crates/lazaret-ignored-workspace/src/daemon.rs
Normal file
189
crates/lazaret-ignored-workspace/src/daemon.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
use std::ffi::{OsStr, OsString};
|
||||
use std::fs::File;
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use rustix::fs::{FileType, Mode, OFlags, ResolveFlags, fstat, openat2};
|
||||
|
||||
use crate::IgnoredWorkspace;
|
||||
use crate::filesystem::RouterConfiguration;
|
||||
|
||||
pub const PRIVATE_ROUTER_ROLE: &str = "--lazaret-private-ignored-workspace-router-v1";
|
||||
|
||||
#[must_use]
|
||||
pub fn dispatch_private_router(arguments: &[OsString]) -> Option<io::Result<()>> {
|
||||
if arguments
|
||||
.first()
|
||||
.is_none_or(|argument| argument != PRIVATE_ROUTER_ROLE)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(parse(arguments).and_then(run))
|
||||
}
|
||||
|
||||
struct Arguments {
|
||||
workspace_fd: i32,
|
||||
workspace_label: PathBuf,
|
||||
isolated_fd: i32,
|
||||
mountpoint: PathBuf,
|
||||
global_excludes: Option<PathBuf>,
|
||||
isolate_ignored: bool,
|
||||
threads: usize,
|
||||
}
|
||||
|
||||
fn parse(arguments: &[OsString]) -> io::Result<Arguments> {
|
||||
let mut arguments = arguments.iter().skip(1);
|
||||
let workspace_fd = number(named(&mut arguments, "--workspace-fd")?)?;
|
||||
let workspace_label = PathBuf::from(named(&mut arguments, "--workspace-label")?);
|
||||
let isolated_fd = number(named(&mut arguments, "--isolated-fd")?)?;
|
||||
let mountpoint = PathBuf::from(named(&mut arguments, "--mountpoint")?);
|
||||
let global_excludes = match named(&mut arguments, "--global-excludes")? {
|
||||
value if value.is_empty() => None,
|
||||
value => Some(PathBuf::from(value)),
|
||||
};
|
||||
let isolate_ignored = match named(&mut arguments, "--isolate-ignored")?.to_str() {
|
||||
Some("true") => true,
|
||||
Some("false") => false,
|
||||
_ => return Err(io::Error::other("router isolation mode is invalid")),
|
||||
};
|
||||
let threads = number(named(&mut arguments, "--threads")?)?;
|
||||
if arguments.next().is_some() || threads == 0 {
|
||||
return Err(io::Error::other("router arguments are invalid"));
|
||||
}
|
||||
Ok(Arguments {
|
||||
workspace_fd,
|
||||
workspace_label,
|
||||
isolated_fd,
|
||||
mountpoint,
|
||||
global_excludes,
|
||||
isolate_ignored,
|
||||
threads,
|
||||
})
|
||||
}
|
||||
|
||||
fn run(arguments: Arguments) -> io::Result<()> {
|
||||
let workspace = inherited_exact_directory(arguments.workspace_fd, &arguments.workspace_label)?;
|
||||
let isolated = inherited(arguments.isolated_fd)?;
|
||||
IgnoredWorkspace::serve_from_authorities(
|
||||
RouterConfiguration {
|
||||
workspace_label: arguments.workspace_label,
|
||||
workspace,
|
||||
isolated_state: isolated,
|
||||
mountpoint: arguments.mountpoint,
|
||||
global_excludes: arguments.global_excludes,
|
||||
isolate_ignored: arguments.isolate_ignored,
|
||||
threads: arguments.threads,
|
||||
},
|
||||
|| {
|
||||
let mut output = io::stdout().lock();
|
||||
output.write_all(b"ready\n")?;
|
||||
output.flush()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn inherited_exact_directory(descriptor: i32, label: &std::path::Path) -> io::Result<File> {
|
||||
let inherited = inherited(descriptor)?;
|
||||
let expected = fstat(&inherited)?;
|
||||
let reopened = File::from(openat2(
|
||||
rustix::fs::CWD,
|
||||
label,
|
||||
OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC | OFlags::NOFOLLOW,
|
||||
Mode::empty(),
|
||||
ResolveFlags::NO_MAGICLINKS | ResolveFlags::NO_SYMLINKS,
|
||||
)?);
|
||||
let observed = fstat(&reopened)?;
|
||||
if expected.st_dev != observed.st_dev
|
||||
|| expected.st_ino != observed.st_ino
|
||||
|| !FileType::from_raw_mode(observed.st_mode).is_dir()
|
||||
{
|
||||
return Err(io::Error::other(
|
||||
"namespace-local workspace differs from inherited authority",
|
||||
));
|
||||
}
|
||||
Ok(reopened)
|
||||
}
|
||||
|
||||
fn inherited(descriptor: i32) -> io::Result<File> {
|
||||
if descriptor < 0 {
|
||||
return Err(io::Error::other("router descriptor is invalid"));
|
||||
}
|
||||
File::open(format!("/proc/self/fd/{descriptor}"))
|
||||
}
|
||||
|
||||
fn named<'a>(
|
||||
arguments: &mut impl Iterator<Item = &'a OsString>,
|
||||
name: &str,
|
||||
) -> io::Result<&'a OsString> {
|
||||
if arguments.next().is_none_or(|argument| argument != name) {
|
||||
return Err(io::Error::other(format!(
|
||||
"router argument {name} is missing"
|
||||
)));
|
||||
}
|
||||
arguments
|
||||
.next()
|
||||
.ok_or_else(|| io::Error::other(format!("router argument {name} has no value")))
|
||||
}
|
||||
|
||||
fn number<T>(value: &OsStr) -> io::Result<T>
|
||||
where
|
||||
T: std::str::FromStr,
|
||||
{
|
||||
value
|
||||
.to_str()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.ok_or_else(|| io::Error::other("router numeric argument is invalid"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_private_arguments_parse() {
|
||||
let arguments = valid_arguments();
|
||||
let parsed = parse(&arguments).unwrap();
|
||||
assert_eq!(parsed.workspace_fd, 3);
|
||||
assert_eq!(parsed.isolated_fd, 4);
|
||||
assert_eq!(parsed.threads, 2);
|
||||
assert!(parsed.isolate_ignored);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_private_arguments_are_refused() {
|
||||
let mut extra = valid_arguments();
|
||||
extra.push("unexpected".into());
|
||||
assert!(parse(&extra).is_err());
|
||||
|
||||
let mut zero_threads = valid_arguments();
|
||||
*zero_threads.last_mut().unwrap() = "0".into();
|
||||
assert!(parse(&zero_threads).is_err());
|
||||
|
||||
let mut missing = valid_arguments();
|
||||
missing.remove(2);
|
||||
assert!(parse(&missing).is_err());
|
||||
}
|
||||
|
||||
fn valid_arguments() -> Vec<OsString> {
|
||||
[
|
||||
PRIVATE_ROUTER_ROLE,
|
||||
"--workspace-fd",
|
||||
"3",
|
||||
"--workspace-label",
|
||||
"/workspace",
|
||||
"--isolated-fd",
|
||||
"4",
|
||||
"--mountpoint",
|
||||
"/runtime/workspace",
|
||||
"--global-excludes",
|
||||
"",
|
||||
"--isolate-ignored",
|
||||
"true",
|
||||
"--threads",
|
||||
"2",
|
||||
]
|
||||
.into_iter()
|
||||
.map(OsString::from)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
|
@ -44,10 +44,21 @@ pub struct MountConfiguration {
|
|||
pub threads: usize,
|
||||
}
|
||||
|
||||
pub(crate) struct RouterConfiguration {
|
||||
pub workspace_label: PathBuf,
|
||||
pub workspace: File,
|
||||
pub isolated_state: File,
|
||||
pub mountpoint: PathBuf,
|
||||
pub global_excludes: Option<PathBuf>,
|
||||
pub isolate_ignored: bool,
|
||||
pub threads: usize,
|
||||
}
|
||||
|
||||
pub struct IgnoredWorkspace {
|
||||
host: BackingRoot,
|
||||
isolated: BackingRoot,
|
||||
classifier: GitClassifier,
|
||||
isolate_ignored: bool,
|
||||
nodes: Mutex<NodeTable>,
|
||||
files: Mutex<HashMap<u64, Arc<OpenFile>>>,
|
||||
directories: Mutex<HashMap<u64, DirectoryStream>>,
|
||||
|
|
@ -55,6 +66,21 @@ pub struct IgnoredWorkspace {
|
|||
next_handle: AtomicU64,
|
||||
}
|
||||
|
||||
fn mount_config(threads: usize) -> Config {
|
||||
let mut config = Config::default();
|
||||
config.mount_options.extend([
|
||||
MountOption::FSName("lazaret-ignored-workspace".to_owned()),
|
||||
MountOption::RW,
|
||||
MountOption::Exec,
|
||||
MountOption::NoSuid,
|
||||
MountOption::NoDev,
|
||||
MountOption::DefaultPermissions,
|
||||
]);
|
||||
config.n_threads = Some(threads.max(1));
|
||||
config.clone_fd = true;
|
||||
config
|
||||
}
|
||||
|
||||
impl IgnoredWorkspace {
|
||||
/// # Errors
|
||||
/// Refuses roots that are not distinct, descriptor-openable directories in a Git workspace.
|
||||
|
|
@ -86,11 +112,41 @@ impl IgnoredWorkspace {
|
|||
Self::from_roots(workspace_label, host, isolated, global_excludes)
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Refuses aliased or non-directory authorities and unreadable Git policy inputs.
|
||||
pub fn from_authorities_with_policy(
|
||||
workspace_label: PathBuf,
|
||||
workspace: File,
|
||||
isolated_state: File,
|
||||
global_excludes: Option<PathBuf>,
|
||||
isolate_ignored: bool,
|
||||
) -> io::Result<Self> {
|
||||
let host = BackingRoot::from_file(workspace)?;
|
||||
let isolated = BackingRoot::from_file(isolated_state)?;
|
||||
Self::from_roots_with_policy(
|
||||
workspace_label,
|
||||
host,
|
||||
isolated,
|
||||
global_excludes,
|
||||
isolate_ignored,
|
||||
)
|
||||
}
|
||||
|
||||
fn from_roots(
|
||||
workspace_label: PathBuf,
|
||||
host: BackingRoot,
|
||||
isolated: BackingRoot,
|
||||
global_excludes: Option<PathBuf>,
|
||||
) -> io::Result<Self> {
|
||||
Self::from_roots_with_policy(workspace_label, host, isolated, global_excludes, true)
|
||||
}
|
||||
|
||||
fn from_roots_with_policy(
|
||||
workspace_label: PathBuf,
|
||||
host: BackingRoot,
|
||||
isolated: BackingRoot,
|
||||
global_excludes: Option<PathBuf>,
|
||||
isolate_ignored: bool,
|
||||
) -> io::Result<Self> {
|
||||
if host.identity()? == isolated.identity()? {
|
||||
return Err(io::Error::other("workspace and isolated authorities alias"));
|
||||
|
|
@ -102,6 +158,7 @@ impl IgnoredWorkspace {
|
|||
host,
|
||||
isolated,
|
||||
classifier,
|
||||
isolate_ignored,
|
||||
nodes: Mutex::new(NodeTable::new()),
|
||||
files: Mutex::new(HashMap::new()),
|
||||
directories: Mutex::new(HashMap::new()),
|
||||
|
|
@ -118,20 +175,36 @@ impl IgnoredWorkspace {
|
|||
&configuration.isolated_state,
|
||||
configuration.global_excludes,
|
||||
)?;
|
||||
let mut config = Config::default();
|
||||
config.mount_options.extend([
|
||||
MountOption::FSName("lazaret-ignored-workspace".to_owned()),
|
||||
MountOption::RW,
|
||||
MountOption::Exec,
|
||||
MountOption::NoSuid,
|
||||
MountOption::NoDev,
|
||||
MountOption::DefaultPermissions,
|
||||
]);
|
||||
config.n_threads = Some(configuration.threads.max(1));
|
||||
config.clone_fd = true;
|
||||
let config = mount_config(configuration.threads);
|
||||
fuser::mount2(filesystem, configuration.mountpoint, &config)
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Returns mount, readiness, control-channel, unmount, or daemon I/O failures.
|
||||
pub(crate) fn serve_from_authorities(
|
||||
configuration: RouterConfiguration,
|
||||
ready: impl FnOnce() -> io::Result<()>,
|
||||
) -> io::Result<()> {
|
||||
let filesystem = Self::from_authorities_with_policy(
|
||||
configuration.workspace_label,
|
||||
configuration.workspace,
|
||||
configuration.isolated_state,
|
||||
configuration.global_excludes,
|
||||
configuration.isolate_ignored,
|
||||
)?;
|
||||
let session = fuser::spawn_mount2(
|
||||
filesystem,
|
||||
configuration.mountpoint,
|
||||
&mount_config(configuration.threads),
|
||||
)?;
|
||||
if let Err(error) = ready() {
|
||||
let _ = session.umount_and_join();
|
||||
return Err(error);
|
||||
}
|
||||
std::io::copy(&mut std::io::stdin().lock(), &mut std::io::sink())?;
|
||||
session.umount_and_join()
|
||||
}
|
||||
|
||||
fn root(&self, backing: Classification) -> &BackingRoot {
|
||||
match backing {
|
||||
Classification::Host => &self.host,
|
||||
|
|
@ -171,7 +244,7 @@ impl IgnoredWorkspace {
|
|||
.as_ref()
|
||||
.or(host.as_ref())
|
||||
.is_some_and(|stat| stat.st_mode & libc::S_IFMT == libc::S_IFDIR);
|
||||
let backing = snapshot.classify(path, directory);
|
||||
let backing = self.classify(snapshot, path, directory);
|
||||
if snapshot.is_tracked(path) {
|
||||
if let (Some(host), Some(isolated)) = (&host, &isolated)
|
||||
&& host.st_mode & libc::S_IFMT != isolated.st_mode & libc::S_IFMT
|
||||
|
|
@ -200,13 +273,26 @@ impl IgnoredWorkspace {
|
|||
path: &Path,
|
||||
directory: bool,
|
||||
) -> io::Result<Classification> {
|
||||
let backing = snapshot.classify(path, directory);
|
||||
let backing = self.classify(snapshot, path, directory);
|
||||
if backing == Classification::Isolated {
|
||||
self.isolated.ensure_parent_directories(path)?;
|
||||
}
|
||||
Ok(backing)
|
||||
}
|
||||
|
||||
fn classify(
|
||||
&self,
|
||||
snapshot: &ClassificationSnapshot,
|
||||
path: &Path,
|
||||
directory: bool,
|
||||
) -> Classification {
|
||||
if self.isolate_ignored {
|
||||
snapshot.classify(path, directory)
|
||||
} else {
|
||||
Classification::Host
|
||||
}
|
||||
}
|
||||
|
||||
fn open_file(&self, inode: INodeNo, flags: i32) -> io::Result<u64> {
|
||||
let path = self.path_for(inode)?;
|
||||
let snapshot = self.classifier.snapshot_for(&path);
|
||||
|
|
@ -262,7 +348,7 @@ impl IgnoredWorkspace {
|
|||
.resolve(&snapshot, path)
|
||||
.and_then(|entry| self.attributes(&entry))
|
||||
{
|
||||
Ok(attributes) => reply.entry(&TTL, &attributes, Generation(snapshot.generation())),
|
||||
Ok(attributes) => reply.entry(&TTL, &attributes, Generation(0)),
|
||||
Err(error) => reply.error(errno(&error)),
|
||||
}
|
||||
}
|
||||
|
|
@ -643,7 +729,8 @@ impl Filesystem for IgnoredWorkspace {
|
|||
let destination = self.child_path(new_parent, new_name)?;
|
||||
let snapshot = self.classifier.snapshot_for_paths(&[&source, &destination]);
|
||||
let source_entry = self.resolve(&snapshot, &source)?;
|
||||
let destination_backing = snapshot.classify(
|
||||
let destination_backing = self.classify(
|
||||
&snapshot,
|
||||
&destination,
|
||||
source_entry.stat.st_mode & libc::S_IFMT == libc::S_IFDIR,
|
||||
);
|
||||
|
|
@ -673,7 +760,7 @@ impl Filesystem for IgnoredWorkspace {
|
|||
let destination = self.child_path(new_parent, new_name)?;
|
||||
let snapshot = self.classifier.snapshot_for_paths(&[&source, &destination]);
|
||||
let source_entry = self.resolve(&snapshot, &source)?;
|
||||
let destination_backing = snapshot.classify(&destination, false);
|
||||
let destination_backing = self.classify(&snapshot, &destination, false);
|
||||
if source_entry.backing != destination_backing {
|
||||
return Err(io::Error::from_raw_os_error(libc::EXDEV));
|
||||
}
|
||||
|
|
@ -1105,13 +1192,13 @@ impl Filesystem for IgnoredWorkspace {
|
|||
access_mode: flags & libc::O_ACCMODE,
|
||||
}),
|
||||
);
|
||||
Ok((attributes, handle, snapshot.generation()))
|
||||
Ok((attributes, handle))
|
||||
})();
|
||||
match result {
|
||||
Ok((attributes, handle, generation)) => reply.created(
|
||||
Ok((attributes, handle)) => reply.created(
|
||||
&TTL,
|
||||
&attributes,
|
||||
Generation(generation),
|
||||
Generation(0),
|
||||
FileHandle(handle),
|
||||
FopenFlags::empty(),
|
||||
),
|
||||
|
|
@ -1552,6 +1639,27 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_isolation_routes_ignored_paths_to_the_host_backing() {
|
||||
let fixture = Fixture::new();
|
||||
repository(&fixture.workspace);
|
||||
fs::write(fixture.workspace.join(".gitignore"), b"ignored/\n").unwrap();
|
||||
let filesystem = IgnoredWorkspace::from_authorities_with_policy(
|
||||
fixture.workspace.clone(),
|
||||
fs::File::open(&fixture.workspace).unwrap(),
|
||||
fs::File::open(&fixture.isolated).unwrap(),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let path = std::path::Path::new("ignored/item");
|
||||
let snapshot = filesystem.classifier.snapshot_for(path);
|
||||
assert_eq!(
|
||||
filesystem.classify(&snapshot, path, false),
|
||||
Classification::Host
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a usable host FUSE device"]
|
||||
fn physical_router_preserves_lock_domains_across_transition() {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
mod backing;
|
||||
mod classifier;
|
||||
mod daemon;
|
||||
mod filesystem;
|
||||
mod sentry;
|
||||
|
||||
pub use classifier::{Classification, ClassificationSnapshot, GitClassifier};
|
||||
pub use classifier::{Classification, ClassificationSnapshot, GitClassifier, git_common_directory};
|
||||
pub use daemon::{PRIVATE_ROUTER_ROLE, dispatch_private_router};
|
||||
pub use filesystem::{IgnoredWorkspace, MountConfiguration};
|
||||
pub use sentry::{GitSentry, GitSentryConfiguration};
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::os::fd::{AsRawFd, RawFd};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rustix::fs::{Mode, OFlags};
|
||||
use rustix::mount::{MountFlags, UnmountFlags, mount_bind, mount_remount, unmount};
|
||||
use rustix::fs::{FileType, Mode, OFlags, fstat};
|
||||
use rustix::mount::{
|
||||
MountFlags, MoveMountFlags, OpenTreeFlags, UnmountFlags, mount_remount, move_mount, open_tree,
|
||||
unmount,
|
||||
};
|
||||
|
||||
use crate::backing::BackingRoot;
|
||||
|
||||
|
|
@ -13,87 +15,254 @@ pub struct GitSentryConfiguration {
|
|||
pub mounted_workspace: PathBuf,
|
||||
pub host_git_common_directory: PathBuf,
|
||||
pub transient_hooks: PathBuf,
|
||||
pub transient_config: PathBuf,
|
||||
}
|
||||
|
||||
pub struct GitSentry {
|
||||
config_target: File,
|
||||
hook_target: File,
|
||||
config: InstalledMount,
|
||||
hooks: InstalledMount,
|
||||
}
|
||||
|
||||
struct InstalledMount {
|
||||
mounted_target: File,
|
||||
cleanup_target: File,
|
||||
cleanup_label: PathBuf,
|
||||
}
|
||||
|
||||
impl InstalledMount {
|
||||
fn unmount(self) -> io::Result<()> {
|
||||
descriptor_move(&self.mounted_target, &self.cleanup_target)?;
|
||||
unmount(self.cleanup_label, UnmountFlags::DETACH).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl GitSentry {
|
||||
/// # Errors
|
||||
/// Refuses linked or missing sources and targets and incomplete protective mounts.
|
||||
pub fn install(configuration: &GitSentryConfiguration) -> io::Result<Self> {
|
||||
let _workspace = BackingRoot::open(&configuration.mounted_workspace)?;
|
||||
let workspace = BackingRoot::open(&configuration.mounted_workspace)?;
|
||||
let common = BackingRoot::open(&configuration.host_git_common_directory)?;
|
||||
let transient = BackingRoot::open(&configuration.transient_hooks)?;
|
||||
|
||||
let hook_source = transient.duplicate()?;
|
||||
let hook_target = common.open_entry(
|
||||
Path::new("hooks"),
|
||||
OFlags::RDONLY | OFlags::DIRECTORY,
|
||||
Mode::empty(),
|
||||
)?;
|
||||
descriptor_bind(&hook_source, &hook_target)?;
|
||||
let hook_target = common.open_entry(
|
||||
Path::new("hooks"),
|
||||
OFlags::RDONLY | OFlags::DIRECTORY,
|
||||
Mode::empty(),
|
||||
)?;
|
||||
|
||||
let config_source =
|
||||
common.open_entry(Path::new("config"), OFlags::RDONLY, Mode::empty())?;
|
||||
let config_target =
|
||||
common.open_entry(Path::new("config"), OFlags::RDONLY, Mode::empty())?;
|
||||
descriptor_bind(&config_source, &config_target)?;
|
||||
let config_target =
|
||||
common.open_entry(Path::new("config"), OFlags::RDONLY, Mode::empty())?;
|
||||
if let Err(error) = descriptor_remount_read_only(&config_target) {
|
||||
let _ = descriptor_unmount(&hook_target);
|
||||
return Err(error);
|
||||
let transient_hooks = BackingRoot::open(&configuration.transient_hooks)?;
|
||||
let transient_config = File::options()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&configuration.transient_config)?;
|
||||
Self::install_from_roots(
|
||||
&workspace,
|
||||
&common,
|
||||
&transient_hooks,
|
||||
configuration.transient_hooks.clone(),
|
||||
transient_config,
|
||||
configuration.transient_config.clone(),
|
||||
)
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Refuses aliased, linked, or incomplete authorities and incomplete protective mounts.
|
||||
pub fn install_from_authorities(
|
||||
mounted_workspace: File,
|
||||
host_git_common_directory: File,
|
||||
transient_hooks: File,
|
||||
transient_hooks_label: PathBuf,
|
||||
transient_config: File,
|
||||
transient_config_label: PathBuf,
|
||||
) -> io::Result<Self> {
|
||||
let workspace = BackingRoot::from_file(mounted_workspace)?;
|
||||
let common = BackingRoot::from_file(host_git_common_directory)?;
|
||||
let transient_hooks = BackingRoot::from_file(transient_hooks)?;
|
||||
Self::install_from_roots(
|
||||
&workspace,
|
||||
&common,
|
||||
&transient_hooks,
|
||||
transient_hooks_label,
|
||||
transient_config,
|
||||
transient_config_label,
|
||||
)
|
||||
}
|
||||
|
||||
fn install_from_roots(
|
||||
_workspace: &BackingRoot,
|
||||
common: &BackingRoot,
|
||||
transient_hooks: &BackingRoot,
|
||||
transient_hooks_label: PathBuf,
|
||||
transient_config: File,
|
||||
transient_config_label: PathBuf,
|
||||
) -> io::Result<Self> {
|
||||
require_empty_regular_file(&transient_config)
|
||||
.map_err(|error| sentry_error("validating transient config", &error))?;
|
||||
let config_source = common
|
||||
.open_entry(Path::new("config"), OFlags::RDONLY, Mode::empty())
|
||||
.map_err(|error| sentry_error("opening host config", &error))?;
|
||||
require_regular_file(&config_source)
|
||||
.map_err(|error| sentry_error("validating host config", &error))?;
|
||||
let staged_config = File::from(
|
||||
descriptor_bind(&config_source, &transient_config)
|
||||
.map_err(|error| sentry_error("staging config mount", &error))?,
|
||||
);
|
||||
if let Err(error) = descriptor_remount_read_only(&transient_config_label)
|
||||
.map_err(|error| sentry_error("making config mount read-only", &error))
|
||||
{
|
||||
return Err(cleanup_staged(error, &transient_config_label));
|
||||
}
|
||||
let config_target =
|
||||
match common.open_entry(Path::new("config"), OFlags::RDONLY, Mode::empty()) {
|
||||
Ok(config_target) => config_target,
|
||||
Err(error) => {
|
||||
return Err(cleanup_staged(
|
||||
sentry_error("opening config mount target", &error),
|
||||
&transient_config_label,
|
||||
));
|
||||
}
|
||||
};
|
||||
if let Err(error) = descriptor_move(&staged_config, &config_target)
|
||||
.map_err(|error| sentry_error("installing config mount", &error))
|
||||
{
|
||||
return Err(cleanup_staged(error, &transient_config_label));
|
||||
}
|
||||
let config = InstalledMount {
|
||||
mounted_target: staged_config,
|
||||
cleanup_target: transient_config,
|
||||
cleanup_label: transient_config_label,
|
||||
};
|
||||
|
||||
let hook_source = match transient_hooks.duplicate() {
|
||||
Ok(hook_source) => hook_source,
|
||||
Err(error) => {
|
||||
return Err(cleanup_installed(
|
||||
sentry_error("retaining transient hooks", &error),
|
||||
config,
|
||||
));
|
||||
}
|
||||
};
|
||||
let hook_target = match common.open_entry(
|
||||
Path::new("hooks"),
|
||||
OFlags::RDONLY | OFlags::DIRECTORY,
|
||||
Mode::empty(),
|
||||
) {
|
||||
Ok(hook_target) => hook_target,
|
||||
Err(error) => {
|
||||
return Err(cleanup_installed(
|
||||
sentry_error("opening hooks mount target", &error),
|
||||
config,
|
||||
));
|
||||
}
|
||||
};
|
||||
let mounted_hooks = match descriptor_bind(&hook_source, &hook_target)
|
||||
.map_err(|error| sentry_error("installing hooks mount", &error))
|
||||
{
|
||||
Ok(mounted_hooks) => File::from(mounted_hooks),
|
||||
Err(error) => {
|
||||
return Err(cleanup_installed(error, config));
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
config_target,
|
||||
hook_target,
|
||||
config,
|
||||
hooks: InstalledMount {
|
||||
mounted_target: mounted_hooks,
|
||||
cleanup_target: hook_source,
|
||||
cleanup_label: transient_hooks_label,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// # Errors
|
||||
/// Returns an error unless both exact protective mountpoints are detached.
|
||||
pub fn unmount(self) -> io::Result<()> {
|
||||
let config = descriptor_unmount(&self.config_target);
|
||||
let hooks = descriptor_unmount(&self.hook_target);
|
||||
config.and(hooks)
|
||||
let config = self.config.unmount();
|
||||
let hooks = self.hooks.unmount();
|
||||
match (config, hooks) {
|
||||
(Ok(()), Ok(())) => Ok(()),
|
||||
(config, hooks) => Err(io::Error::other(format!(
|
||||
"config={config:?}, hooks={hooks:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn descriptor_bind(source: &File, target: &File) -> io::Result<()> {
|
||||
Ok(mount_bind(
|
||||
proc_fd(source.as_raw_fd()),
|
||||
proc_fd(target.as_raw_fd()),
|
||||
)?)
|
||||
fn sentry_error(action: &str, error: &io::Error) -> io::Error {
|
||||
io::Error::new(error.kind(), format!("{action}: {error}"))
|
||||
}
|
||||
|
||||
fn descriptor_remount_read_only(target: &File) -> io::Result<()> {
|
||||
let result = mount_remount(
|
||||
proc_fd(target.as_raw_fd()),
|
||||
fn cleanup_staged(error: io::Error, label: &Path) -> io::Error {
|
||||
combine_cleanup(
|
||||
error,
|
||||
unmount(label, UnmountFlags::DETACH).map_err(Into::into),
|
||||
)
|
||||
}
|
||||
|
||||
fn cleanup_installed(error: io::Error, mount: InstalledMount) -> io::Error {
|
||||
combine_cleanup(error, mount.unmount())
|
||||
}
|
||||
|
||||
fn combine_cleanup(error: io::Error, cleanup: io::Result<()>) -> io::Error {
|
||||
match cleanup {
|
||||
Ok(()) => error,
|
||||
Err(cleanup) => io::Error::new(error.kind(), format!("{error}; cleanup failed: {cleanup}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn descriptor_bind(source: &File, target: &File) -> io::Result<std::os::fd::OwnedFd> {
|
||||
let mount = open_tree(
|
||||
source,
|
||||
"",
|
||||
OpenTreeFlags::OPEN_TREE_CLONE
|
||||
| OpenTreeFlags::OPEN_TREE_CLOEXEC
|
||||
| OpenTreeFlags::AT_EMPTY_PATH,
|
||||
)?;
|
||||
move_mount(
|
||||
&mount,
|
||||
"",
|
||||
target,
|
||||
"",
|
||||
MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH | MoveMountFlags::MOVE_MOUNT_T_EMPTY_PATH,
|
||||
)
|
||||
.map_err(io::Error::from)?;
|
||||
Ok(mount)
|
||||
}
|
||||
|
||||
fn descriptor_move(source: &File, target: &File) -> io::Result<()> {
|
||||
move_mount(
|
||||
source,
|
||||
"",
|
||||
target,
|
||||
"",
|
||||
MoveMountFlags::MOVE_MOUNT_F_EMPTY_PATH | MoveMountFlags::MOVE_MOUNT_T_EMPTY_PATH,
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
fn descriptor_remount_read_only(target: &Path) -> io::Result<()> {
|
||||
mount_remount(
|
||||
target,
|
||||
MountFlags::BIND
|
||||
| MountFlags::RDONLY
|
||||
| MountFlags::NOSUID
|
||||
| MountFlags::NODEV
|
||||
| MountFlags::NOEXEC,
|
||||
"",
|
||||
);
|
||||
if result.is_err() {
|
||||
let _ = unmount(proc_fd(target.as_raw_fd()), UnmountFlags::DETACH);
|
||||
}
|
||||
Ok(result?)
|
||||
)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
fn descriptor_unmount(target: &File) -> io::Result<()> {
|
||||
Ok(unmount(proc_fd(target.as_raw_fd()), UnmountFlags::DETACH)?)
|
||||
fn require_empty_regular_file(file: &File) -> io::Result<()> {
|
||||
let metadata = fstat(file)?;
|
||||
if !FileType::from_raw_mode(metadata.st_mode).is_file() {
|
||||
return Err(io::Error::other(
|
||||
"transient Git config authority is not a regular file",
|
||||
));
|
||||
}
|
||||
if metadata.st_size != 0 {
|
||||
return Err(io::Error::other(
|
||||
"transient Git config authority is not an empty regular file",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn proc_fd(fd: RawFd) -> PathBuf {
|
||||
PathBuf::from(format!("/proc/self/fd/{fd}"))
|
||||
fn require_regular_file(file: &File) -> io::Result<()> {
|
||||
if FileType::from_raw_mode(fstat(file)?.st_mode).is_file() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::other("host Git config is not a regular file"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ path = "src/main.rs"
|
|||
|
||||
[dependencies]
|
||||
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" }
|
||||
lazaret-ignored-workspace = { version = "=0.0.0", path = "../lazaret-ignored-workspace" }
|
||||
lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" }
|
||||
lazaret-state-anchor = { version = "=0.0.0", path = "../lazaret-state-anchor" }
|
||||
oci-spec.workspace = true
|
||||
|
|
|
|||
|
|
@ -7,6 +7,13 @@ fn main() {
|
|||
}
|
||||
return;
|
||||
}
|
||||
if let Some(result) = lazaret_ignored_workspace::dispatch_private_router(&arguments) {
|
||||
if let Err(error) = result {
|
||||
eprintln!("ignored workspace router failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some(result) =
|
||||
lazaret_runtime_supervisor::supervision::dispatch_private_runtime_role(&arguments)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -625,6 +625,25 @@ pub fn production_sandbox_layout() -> Result<SandboxLayoutV1, SandboxBuildError>
|
|||
/// Refuses changed host authority, namespace mismatch, invalid paths, or encoding failures.
|
||||
pub fn build_sandbox_bundle(
|
||||
request: SandboxBuildRequest<'_>,
|
||||
) -> Result<SandboxBundle, SandboxBuildError> {
|
||||
let workspace_source = host_path(request.configuration.workspace().canonical_label())?;
|
||||
build_sandbox_bundle_from_source(request, workspace_source)
|
||||
}
|
||||
|
||||
/// Builds deterministic OCI bytes using a generation-local routed workspace source.
|
||||
///
|
||||
/// # Errors
|
||||
/// Refuses an invalid source, changed host authority, namespace mismatch, or encoding failure.
|
||||
pub fn build_sandbox_bundle_with_workspace(
|
||||
request: SandboxBuildRequest<'_>,
|
||||
workspace_source: PathBuf,
|
||||
) -> Result<SandboxBundle, SandboxBuildError> {
|
||||
build_sandbox_bundle_from_source(request, workspace_source)
|
||||
}
|
||||
|
||||
fn build_sandbox_bundle_from_source(
|
||||
request: SandboxBuildRequest<'_>,
|
||||
workspace_source: PathBuf,
|
||||
) -> Result<SandboxBundle, SandboxBuildError> {
|
||||
require_network_namespace(request.expected_network_namespace)?;
|
||||
request
|
||||
|
|
@ -648,6 +667,7 @@ pub fn build_sandbox_bundle(
|
|||
request.configuration,
|
||||
request.terminal.as_ref(),
|
||||
&paths.control_sources,
|
||||
workspace_source,
|
||||
)?;
|
||||
let process = build_process(
|
||||
request.configuration,
|
||||
|
|
@ -873,10 +893,11 @@ fn build_mounts(
|
|||
configuration: &ResolvedDomainConfiguration,
|
||||
terminal: Option<&StagedHostTerminal>,
|
||||
control_sources: &Path,
|
||||
workspace_source: PathBuf,
|
||||
) -> Result<Vec<Mount>, SandboxBuildError> {
|
||||
let mut mounts = standard_mounts()?;
|
||||
mounts.push(bind_mount(
|
||||
host_path(configuration.workspace().canonical_label())?,
|
||||
workspace_source,
|
||||
PathBuf::from(WORKSPACE_DESTINATION),
|
||||
false,
|
||||
false,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,7 +13,7 @@ use lazaret_runtime_supervisor::sandbox::{
|
|||
MAILBOX_REQUESTS_DESTINATION, MAILBOX_RESULTS_DESTINATION, NETWORK_NAMESPACE_PATH, RootProcess,
|
||||
SANDBOX_INIT_DESTINATION, SandboxBuildErrorKind, SandboxBuildRequest, SandboxBundle,
|
||||
TERMINFO_ROOT_DESTINATION, WORKSPACE_DESTINATION, build_sandbox_bundle,
|
||||
production_sandbox_layout, stage_host_terminal,
|
||||
build_sandbox_bundle_with_workspace, production_sandbox_layout, stage_host_terminal,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
|
|
@ -324,6 +324,20 @@ fn deterministic_snapshot_has_only_private_namespaces_and_fixed_runtime_shape()
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routed_workspace_source_replaces_only_the_workspace_bind_source() {
|
||||
let fixture = Fixture::new();
|
||||
let routed = fixture.tree.path().join("runtime/routed-workspace");
|
||||
let bundle =
|
||||
build_sandbox_bundle_with_workspace(fixture.request(root_process([])), routed.clone())
|
||||
.unwrap();
|
||||
let specification = value(&bundle);
|
||||
assert_eq!(
|
||||
mounts(&specification)[WORKSPACE_DESTINATION]["source"],
|
||||
routed.to_string_lossy().as_ref()
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_runtime_command(bundle: &SandboxBundle, fixture: &Fixture) {
|
||||
assert_eq!(bundle.command().program(), OsStr::new("runsc"));
|
||||
let runtime_domain = String::from_utf8(
|
||||
|
|
|
|||
Loading…
Reference in a new issue