Integrate routed Git workspaces with persistent state

This commit is contained in:
ms2k 2026-07-18 02:08:14 +09:00
commit 917409fcc6
20 changed files with 2363 additions and 119 deletions

2
Cargo.lock generated
View file

@ -1255,6 +1255,7 @@ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"lazaret-core", "lazaret-core",
"lazaret-escalation-client", "lazaret-escalation-client",
"lazaret-ignored-workspace",
"lazaret-linux", "lazaret-linux",
"lazaret-prepared-lower", "lazaret-prepared-lower",
"lazaret-runtime-supervisor", "lazaret-runtime-supervisor",
@ -1321,6 +1322,7 @@ name = "lazaret-runtime-supervisor"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"lazaret-core", "lazaret-core",
"lazaret-ignored-workspace",
"lazaret-linux", "lazaret-linux",
"lazaret-state-anchor", "lazaret-state-anchor",
"oci-spec 0.10.0", "oci-spec 0.10.0",

View file

@ -23,6 +23,7 @@ harness = false
ed25519-dalek.workspace = true ed25519-dalek.workspace = true
getrandom.workspace = true getrandom.workspace = true
lazaret-core = { version = "=0.0.0", path = "../lazaret-core" } 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-linux = { version = "=0.0.0", path = "../lazaret-linux" }
lazaret-prepared-lower = { version = "=0.0.0", path = "../lazaret-prepared-lower" } lazaret-prepared-lower = { version = "=0.0.0", path = "../lazaret-prepared-lower" }
lazaret-runtime-supervisor = { version = "=0.0.0", path = "../lazaret-runtime-supervisor" } lazaret-runtime-supervisor = { version = "=0.0.0", path = "../lazaret-runtime-supervisor" }

View file

@ -4,7 +4,7 @@ use std::sync::Arc;
use std::sync::Mutex; use std::sync::Mutex;
use lazaret_core::configuration::ResolvedDomainConfiguration; 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::network::{CanonicalDerivedNetworkPolicy, ReachabilityExpectation};
use lazaret_core::records::Session; use lazaret_core::records::Session;
use lazaret_core::records::{ use lazaret_core::records::{
@ -24,6 +24,7 @@ use crate::control::{
SessionRegistry, StagedControlFace, TrustAnchor, SessionRegistry, StagedControlFace, TrustAnchor,
}; };
use crate::domain_store::{DomainStoreMutation, HostCoordinatorDomainStore}; use crate::domain_store::{DomainStoreMutation, HostCoordinatorDomainStore};
use crate::git_routing::GitRoutingCoordinator;
use crate::lifecycle::{ use crate::lifecycle::{
HostCoordinatorLifecycle, LifecycleAction, LifecycleAdapter, SessionAdmissionIdentity, HostCoordinatorLifecycle, LifecycleAction, LifecycleAdapter, SessionAdmissionIdentity,
SessionLifecycleAdapter, SessionLifecycleAdapter,
@ -77,6 +78,13 @@ pub struct ProductionBootstrapPlan<'a> {
pub interactive_executor: Arc<dyn InteractiveSessionExecutor>, pub interactive_executor: Arc<dyn InteractiveSessionExecutor>,
pub prompt_transport: Arc<SessionPromptTransport>, pub prompt_transport: Arc<SessionPromptTransport>,
pub(crate) runtime_staging: &'a crate::production::RuntimeStaging, 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> { pub struct ProductionLifecycleAdapter<'a> {
@ -88,6 +96,7 @@ pub struct ProductionLifecycleAdapter<'a> {
awaiting_runtime: Option<AwaitingAttachedRunsc>, awaiting_runtime: Option<AwaitingAttachedRunsc>,
runtime: Option<CommittedSandboxRuntime>, runtime: Option<CommittedSandboxRuntime>,
state_anchor_mount: Option<MountIdentity>, state_anchor_mount: Option<MountIdentity>,
git_routing_mount: Option<MountIdentity>,
state_anchor_release: Option<StateAnchorReleaseAuthority>, state_anchor_release: Option<StateAnchorReleaseAuthority>,
state_anchor_absence: Option<StateAnchorAbsenceObservation>, state_anchor_absence: Option<StateAnchorAbsenceObservation>,
} }
@ -184,6 +193,7 @@ impl<'a> ProductionLifecycleAdapter<'a> {
awaiting_runtime: None, awaiting_runtime: None,
runtime: None, runtime: None,
state_anchor_mount: None, state_anchor_mount: None,
git_routing_mount: None,
state_anchor_release: None, state_anchor_release: None,
state_anchor_absence: None, state_anchor_absence: None,
}, },
@ -286,6 +296,24 @@ impl<'a> ProductionLifecycleAdapter<'a> {
) )
.map_err(|error| failure(format!("mounting State Anchor: {error}")))?; .map_err(|error| failure(format!("mounting State Anchor: {error}")))?;
self.state_anchor_mount = Some(mount); 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(()) Ok(())
} }
@ -465,6 +493,15 @@ impl<'a> ProductionLifecycleAdapter<'a> {
.ok_or_else(|| ProductionLifecycleError("committed runtime is absent".into()))? .ok_or_else(|| ProductionLifecycleError("committed runtime is absent".into()))?
.stop_sandbox(&sandbox) .stop_sandbox(&sandbox)
.map_err(failure)?; .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) self.plan.runtime_staging.cleanup_overlay().map_err(failure)
} }

View 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),
}
}
}

View file

@ -5,6 +5,7 @@ pub mod client_bootstrap;
pub mod control; pub mod control;
pub mod domain_store; pub mod domain_store;
pub mod escalation; pub mod escalation;
pub mod git_routing;
pub mod lifecycle; pub mod lifecycle;
pub mod network; pub mod network;
pub mod production; pub mod production;

View file

@ -32,6 +32,13 @@ fn main() {
} }
return; 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) = if let Some(result) =
lazaret_runtime_supervisor::supervision::dispatch_private_runtime_role(&arguments) lazaret_runtime_supervisor::supervision::dispatch_private_runtime_role(&arguments)
{ {

View file

@ -2,7 +2,7 @@ use std::cell::RefCell;
use std::ffi::OsString; use std::ffi::OsString;
use std::fmt; use std::fmt;
use std::fs::File; use std::fs::File;
use std::io::Write; use std::io::{Read, Write};
use std::os::fd::{AsFd, OwnedFd}; use std::os::fd::{AsFd, OwnedFd};
use std::os::unix::ffi::OsStringExt; use std::os::unix::ffi::OsStringExt;
use std::os::unix::fs::{FileExt, PermissionsExt}; use std::os::unix::fs::{FileExt, PermissionsExt};
@ -10,18 +10,25 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use lazaret_core::configuration::{ConfinedPathPlan, ResolvedDomainConfiguration}; 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::network::{ReachabilityExpectation, derive_ipv4_policy};
use lazaret_core::records::{ use lazaret_core::records::{
Generation, GenerationIdentity, HostProcessOwnershipSlot, IpAddress, PreparedArtifact, Session, Generation, GenerationIdentity, HostProcessOwnershipSlot, IpAddress, PreparedArtifact, Session,
}; };
use lazaret_ignored_workspace::git_common_directory;
use lazaret_linux::filesystem::{mount_identity, namespace_identity}; 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::network::NetworkProbeRequest;
use lazaret_runtime_supervisor::sandbox::{ use lazaret_runtime_supervisor::sandbox::{
RootProcess, SandboxBuildRequest, SandboxBundle, build_sandbox_bundle, 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_state_anchor::{StateAnchorLaunchAuthority, StateAnchorLayout};
use lazaret_storage_catalog::Catalog; use lazaret_storage_catalog::Catalog;
use rustix::event::{PollFd, PollFlags, Timespec, poll}; use rustix::event::{PollFd, PollFlags, Timespec, poll};
@ -30,6 +37,7 @@ use rustix::fs::{
unlinkat, unlinkat,
}; };
use rustix::io::{FdFlags, dup, fcntl_setfd}; use rustix::io::{FdFlags, dup, fcntl_setfd};
use rustix::pipe::{PipeFlags, pipe_with};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use crate::bootstrap::{ use crate::bootstrap::{
@ -141,6 +149,7 @@ pub struct ProductionColdStart<'a> {
bundle_digest: BundleDigest, bundle_digest: BundleDigest,
invoking_uid: u32, invoking_uid: u32,
staging: RuntimeStaging, staging: RuntimeStaging,
git_routing: Option<GitRoutingLayout>,
} }
impl<'a> ProductionColdStart<'a> { impl<'a> ProductionColdStart<'a> {
@ -149,6 +158,7 @@ impl<'a> ProductionColdStart<'a> {
/// # Errors /// # Errors
/// Refuses mismatched generation/configuration/probe inputs, changed host authority, unsafe /// Refuses mismatched generation/configuration/probe inputs, changed host authority, unsafe
/// staging paths, or component construction failure. /// staging paths, or component construction failure.
#[allow(clippy::too_many_lines)]
pub fn stage(inputs: ProductionColdStartInputs<'a>) -> Result<Self, ProductionColdStartError> { pub fn stage(inputs: ProductionColdStartInputs<'a>) -> Result<Self, ProductionColdStartError> {
require_inputs(&inputs)?; require_inputs(&inputs)?;
let runtime_domain = host_path( let runtime_domain = host_path(
@ -168,16 +178,28 @@ impl<'a> ProductionColdStart<'a> {
inputs.invoking_uid, inputs.invoking_uid,
) )
.map_err(|error| failure(format!("runtime staging: {error}")))?; .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)) let expected_network_namespace = namespace_identity(Path::new(NETWORK_NAMESPACE_PATH))
.map_err(|error| failure(format!("network namespace: {error}")))?; .map_err(|error| failure(format!("network namespace: {error}")))?;
let sandbox_bundle = build_sandbox_bundle(SandboxBuildRequest { let request = SandboxBuildRequest {
configuration: inputs.configuration, configuration: inputs.configuration,
container_id: inputs.container_id, container_id: inputs.container_id,
expected_network_namespace, expected_network_namespace,
root_process: inputs.root_process, root_process: inputs.root_process,
terminal: None, terminal: None,
prepared_root: inputs.prepared_artifact_label.to_owned(), 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}")))?; .map_err(|error| failure(format!("sandbox bundle: {error}")))?;
staging staging
.write_bundle(sandbox_bundle.specification_json()) .write_bundle(sandbox_bundle.specification_json())
@ -197,7 +219,7 @@ impl<'a> ProductionColdStart<'a> {
inputs.prepared_artifact_authority, inputs.prepared_artifact_authority,
) )
.map_err(|error| failure(format!("State Anchor authority: {error}")))?; .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, ownership,
sandbox_bundle.command(), sandbox_bundle.command(),
RuntimeNetworkLaunch { RuntimeNetworkLaunch {
@ -208,6 +230,13 @@ impl<'a> ProductionColdStart<'a> {
) )
.map_err(|error| failure(format!("Runtime Supervisor launch: {error}")))? .map_err(|error| failure(format!("Runtime Supervisor launch: {error}")))?
.with_state_anchor(state_anchor); .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 = let bundle_digest =
BundleDigest::new(Sha256::digest(sandbox_bundle.specification_json()).into()); BundleDigest::new(Sha256::digest(sandbox_bundle.specification_json()).into());
let generation_identity = GenerationIdentity { let generation_identity = GenerationIdentity {
@ -236,6 +265,7 @@ impl<'a> ProductionColdStart<'a> {
bundle_digest, bundle_digest,
invoking_uid: inputs.invoking_uid, invoking_uid: inputs.invoking_uid,
staging, staging,
git_routing,
}) })
} }
@ -363,6 +393,13 @@ impl<'a> ProductionColdStart<'a> {
interactive_executor: Arc::new(interactive_executor), interactive_executor: Arc::new(interactive_executor),
prompt_transport, prompt_transport,
runtime_staging: &self.staging, 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(()) 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 { pub(crate) struct RuntimeStaging {
runsc_root: OwnedFd, runsc_root: OwnedFd,
bundles: OwnedFd, bundles: OwnedFd,
@ -778,4 +1062,16 @@ mod tests {
let descriptor: OwnedFd = File::open("/dev/null").unwrap().into(); let descriptor: OwnedFd = File::open("/dev/null").unwrap().into();
assert!(BootstrapOwnerGuard::from_inherited(descriptor).is_err()); 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());
}
} }

View file

@ -26,6 +26,7 @@ use rustix::pipe::{PipeFlags, pipe_with};
use rustix::process::Signal; use rustix::process::Signal;
use crate::domain_store::{DomainStoreError, DomainStoreMutation, HostCoordinatorDomainStore}; use crate::domain_store::{DomainStoreError, DomainStoreMutation, HostCoordinatorDomainStore};
use crate::git_routing::GitRoutingCoordinator;
use crate::network::{NetworkCoordinator, NetworkCoordinatorError}; use crate::network::{NetworkCoordinator, NetworkCoordinatorError};
const SUPERVISOR_EXIT_TIMEOUT: Duration = Duration::from_secs(5); const SUPERVISOR_EXIT_TIMEOUT: Duration = Duration::from_secs(5);
@ -377,6 +378,8 @@ pub struct AwaitingAttachedRunsc {
lifetime: Option<std::os::fd::OwnedFd>, lifetime: Option<std::os::fd::OwnedFd>,
staged: Option<StagedProcessOwnership>, staged: Option<StagedProcessOwnership>,
helper_staged: Option<StagedProcessOwnership>, helper_staged: Option<StagedProcessOwnership>,
git_routing_required: bool,
git_routing_mount: Option<MountIdentity>,
network_evidence: Option<NetworkReadinessEvidence>, network_evidence: Option<NetworkReadinessEvidence>,
runsc_command: lazaret_runtime_supervisor::sandbox::RunscCommand, runsc_command: lazaret_runtime_supervisor::sandbox::RunscCommand,
} }
@ -453,6 +456,67 @@ impl AwaitingAttachedRunsc {
Ok(mount) 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. /// Releases the RS-owned sidecar install path after exact State Anchor readiness.
/// ///
/// # Errors /// # Errors
@ -550,6 +614,8 @@ impl AwaitingAttachedRunsc {
lifetime: Some(coordinator_lifetime), lifetime: Some(coordinator_lifetime),
staged: None, staged: None,
helper_staged: None, helper_staged: None,
git_routing_required: launch.has_git_routing(),
git_routing_mount: None,
network_evidence: None, network_evidence: None,
runsc_command: launch.runsc_command().clone(), runsc_command: launch.runsc_command().clone(),
}) })
@ -693,6 +759,11 @@ impl AwaitingAttachedRunsc {
"attached runsc cannot start before network attestation", "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 let event = self
.control .control
.as_ref() .as_ref()
@ -944,6 +1015,31 @@ impl CommittedSandboxRuntime {
coordinator.complete_clean_after_absence(self.generation_id) 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. /// Stops only the RS-owned network resources after sandbox absence.
/// ///
/// # Errors /// # Errors

View file

@ -41,6 +41,15 @@ fn validated_inputs_stage_one_complete_plan_and_drop_exact_runtime_artifacts() {
assert!(!control.exists()); 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] #[test]
fn cleanup_leaves_a_replacement_directory_untouched() { fn cleanup_leaves_a_replacement_directory_untouched() {
let fixture = ProductionFixture::new(); let fixture = ProductionFixture::new();

View file

@ -13,15 +13,16 @@ fn main() {
fn run() -> io::Result<()> { fn run() -> io::Result<()> {
let arguments = env::args_os().skip(1).collect::<Vec<_>>(); let arguments = env::args_os().skip(1).collect::<Vec<_>>();
if arguments.len() != 3 { if arguments.len() != 4 {
return Err(io::Error::other( 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 { let sentry = GitSentry::install(&GitSentryConfiguration {
mounted_workspace: PathBuf::from(&arguments[0]), mounted_workspace: PathBuf::from(&arguments[0]),
host_git_common_directory: PathBuf::from(&arguments[1]), host_git_common_directory: PathBuf::from(&arguments[1]),
transient_hooks: PathBuf::from(&arguments[2]), transient_hooks: PathBuf::from(&arguments[2]),
transient_config: PathBuf::from(&arguments[3]),
})?; })?;
println!("ready"); println!("ready");
io::stdout().flush()?; io::stdout().flush()?;

View file

@ -46,6 +46,16 @@ pub struct GitClassifier {
current: Mutex<Arc<SnapshotInner>>, 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 { impl GitClassifier {
/// # Errors /// # Errors
/// Refuses a non-repository workspace or unreadable Git policy input. /// Refuses a non-repository workspace or unreadable Git policy input.
@ -386,6 +396,7 @@ struct RepositoryMetadata {
index: PathBuf, index: PathBuf,
config: PathBuf, config: PathBuf,
info_exclude: PathBuf, info_exclude: PathBuf,
common_dir: PathBuf,
} }
fn repository_metadata( fn repository_metadata(
@ -432,6 +443,7 @@ fn repository_metadata(
index: git_dir.join("index"), index: git_dir.join("index"),
config: common_dir.join("config"), config: common_dir.join("config"),
info_exclude: common_dir.join("info/exclude"), info_exclude: common_dir.join("info/exclude"),
common_dir,
}) })
} }

View 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()
}
}

View file

@ -44,10 +44,21 @@ pub struct MountConfiguration {
pub threads: usize, 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 { pub struct IgnoredWorkspace {
host: BackingRoot, host: BackingRoot,
isolated: BackingRoot, isolated: BackingRoot,
classifier: GitClassifier, classifier: GitClassifier,
isolate_ignored: bool,
nodes: Mutex<NodeTable>, nodes: Mutex<NodeTable>,
files: Mutex<HashMap<u64, Arc<OpenFile>>>, files: Mutex<HashMap<u64, Arc<OpenFile>>>,
directories: Mutex<HashMap<u64, DirectoryStream>>, directories: Mutex<HashMap<u64, DirectoryStream>>,
@ -55,6 +66,21 @@ pub struct IgnoredWorkspace {
next_handle: AtomicU64, 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 { impl IgnoredWorkspace {
/// # Errors /// # Errors
/// Refuses roots that are not distinct, descriptor-openable directories in a Git workspace. /// 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) 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( fn from_roots(
workspace_label: PathBuf, workspace_label: PathBuf,
host: BackingRoot, host: BackingRoot,
isolated: BackingRoot, isolated: BackingRoot,
global_excludes: Option<PathBuf>, 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> { ) -> io::Result<Self> {
if host.identity()? == isolated.identity()? { if host.identity()? == isolated.identity()? {
return Err(io::Error::other("workspace and isolated authorities alias")); return Err(io::Error::other("workspace and isolated authorities alias"));
@ -102,6 +158,7 @@ impl IgnoredWorkspace {
host, host,
isolated, isolated,
classifier, classifier,
isolate_ignored,
nodes: Mutex::new(NodeTable::new()), nodes: Mutex::new(NodeTable::new()),
files: Mutex::new(HashMap::new()), files: Mutex::new(HashMap::new()),
directories: Mutex::new(HashMap::new()), directories: Mutex::new(HashMap::new()),
@ -118,20 +175,36 @@ impl IgnoredWorkspace {
&configuration.isolated_state, &configuration.isolated_state,
configuration.global_excludes, configuration.global_excludes,
)?; )?;
let mut config = Config::default(); let config = mount_config(configuration.threads);
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;
fuser::mount2(filesystem, configuration.mountpoint, &config) 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 { fn root(&self, backing: Classification) -> &BackingRoot {
match backing { match backing {
Classification::Host => &self.host, Classification::Host => &self.host,
@ -171,7 +244,7 @@ impl IgnoredWorkspace {
.as_ref() .as_ref()
.or(host.as_ref()) .or(host.as_ref())
.is_some_and(|stat| stat.st_mode & libc::S_IFMT == libc::S_IFDIR); .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 snapshot.is_tracked(path) {
if let (Some(host), Some(isolated)) = (&host, &isolated) if let (Some(host), Some(isolated)) = (&host, &isolated)
&& host.st_mode & libc::S_IFMT != isolated.st_mode & libc::S_IFMT && host.st_mode & libc::S_IFMT != isolated.st_mode & libc::S_IFMT
@ -200,13 +273,26 @@ impl IgnoredWorkspace {
path: &Path, path: &Path,
directory: bool, directory: bool,
) -> io::Result<Classification> { ) -> io::Result<Classification> {
let backing = snapshot.classify(path, directory); let backing = self.classify(snapshot, path, directory);
if backing == Classification::Isolated { if backing == Classification::Isolated {
self.isolated.ensure_parent_directories(path)?; self.isolated.ensure_parent_directories(path)?;
} }
Ok(backing) 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> { fn open_file(&self, inode: INodeNo, flags: i32) -> io::Result<u64> {
let path = self.path_for(inode)?; let path = self.path_for(inode)?;
let snapshot = self.classifier.snapshot_for(&path); let snapshot = self.classifier.snapshot_for(&path);
@ -262,7 +348,7 @@ impl IgnoredWorkspace {
.resolve(&snapshot, path) .resolve(&snapshot, path)
.and_then(|entry| self.attributes(&entry)) .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)), Err(error) => reply.error(errno(&error)),
} }
} }
@ -643,7 +729,8 @@ impl Filesystem for IgnoredWorkspace {
let destination = self.child_path(new_parent, new_name)?; let destination = self.child_path(new_parent, new_name)?;
let snapshot = self.classifier.snapshot_for_paths(&[&source, &destination]); let snapshot = self.classifier.snapshot_for_paths(&[&source, &destination]);
let source_entry = self.resolve(&snapshot, &source)?; let source_entry = self.resolve(&snapshot, &source)?;
let destination_backing = snapshot.classify( let destination_backing = self.classify(
&snapshot,
&destination, &destination,
source_entry.stat.st_mode & libc::S_IFMT == libc::S_IFDIR, 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 destination = self.child_path(new_parent, new_name)?;
let snapshot = self.classifier.snapshot_for_paths(&[&source, &destination]); let snapshot = self.classifier.snapshot_for_paths(&[&source, &destination]);
let source_entry = self.resolve(&snapshot, &source)?; 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 { if source_entry.backing != destination_backing {
return Err(io::Error::from_raw_os_error(libc::EXDEV)); return Err(io::Error::from_raw_os_error(libc::EXDEV));
} }
@ -1105,13 +1192,13 @@ impl Filesystem for IgnoredWorkspace {
access_mode: flags & libc::O_ACCMODE, access_mode: flags & libc::O_ACCMODE,
}), }),
); );
Ok((attributes, handle, snapshot.generation())) Ok((attributes, handle))
})(); })();
match result { match result {
Ok((attributes, handle, generation)) => reply.created( Ok((attributes, handle)) => reply.created(
&TTL, &TTL,
&attributes, &attributes,
Generation(generation), Generation(0),
FileHandle(handle), FileHandle(handle),
FopenFlags::empty(), 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] #[test]
#[ignore = "requires a usable host FUSE device"] #[ignore = "requires a usable host FUSE device"]
fn physical_router_preserves_lock_domains_across_transition() { fn physical_router_preserves_lock_domains_across_transition() {

View file

@ -1,8 +1,10 @@
mod backing; mod backing;
mod classifier; mod classifier;
mod daemon;
mod filesystem; mod filesystem;
mod sentry; 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 filesystem::{IgnoredWorkspace, MountConfiguration};
pub use sentry::{GitSentry, GitSentryConfiguration}; pub use sentry::{GitSentry, GitSentryConfiguration};

View file

@ -1,10 +1,12 @@
use std::fs::File; use std::fs::File;
use std::io; use std::io;
use std::os::fd::{AsRawFd, RawFd};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use rustix::fs::{Mode, OFlags}; use rustix::fs::{FileType, Mode, OFlags, fstat};
use rustix::mount::{MountFlags, UnmountFlags, mount_bind, mount_remount, unmount}; use rustix::mount::{
MountFlags, MoveMountFlags, OpenTreeFlags, UnmountFlags, mount_remount, move_mount, open_tree,
unmount,
};
use crate::backing::BackingRoot; use crate::backing::BackingRoot;
@ -13,87 +15,254 @@ pub struct GitSentryConfiguration {
pub mounted_workspace: PathBuf, pub mounted_workspace: PathBuf,
pub host_git_common_directory: PathBuf, pub host_git_common_directory: PathBuf,
pub transient_hooks: PathBuf, pub transient_hooks: PathBuf,
pub transient_config: PathBuf,
} }
pub struct GitSentry { pub struct GitSentry {
config_target: File, config: InstalledMount,
hook_target: File, 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 { impl GitSentry {
/// # Errors /// # Errors
/// Refuses linked or missing sources and targets and incomplete protective mounts. /// Refuses linked or missing sources and targets and incomplete protective mounts.
pub fn install(configuration: &GitSentryConfiguration) -> io::Result<Self> { 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 common = BackingRoot::open(&configuration.host_git_common_directory)?;
let transient = BackingRoot::open(&configuration.transient_hooks)?; let transient_hooks = BackingRoot::open(&configuration.transient_hooks)?;
let transient_config = File::options()
let hook_source = transient.duplicate()?; .read(true)
let hook_target = common.open_entry( .write(true)
Path::new("hooks"), .open(&configuration.transient_config)?;
OFlags::RDONLY | OFlags::DIRECTORY, Self::install_from_roots(
Mode::empty(), &workspace,
)?; &common,
descriptor_bind(&hook_source, &hook_target)?; &transient_hooks,
let hook_target = common.open_entry( configuration.transient_hooks.clone(),
Path::new("hooks"), transient_config,
OFlags::RDONLY | OFlags::DIRECTORY, configuration.transient_config.clone(),
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);
} }
/// # 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 { Ok(Self {
config_target, config,
hook_target, hooks: InstalledMount {
mounted_target: mounted_hooks,
cleanup_target: hook_source,
cleanup_label: transient_hooks_label,
},
}) })
} }
/// # Errors /// # Errors
/// Returns an error unless both exact protective mountpoints are detached. /// Returns an error unless both exact protective mountpoints are detached.
pub fn unmount(self) -> io::Result<()> { pub fn unmount(self) -> io::Result<()> {
let config = descriptor_unmount(&self.config_target); let config = self.config.unmount();
let hooks = descriptor_unmount(&self.hook_target); let hooks = self.hooks.unmount();
config.and(hooks) 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<()> { fn sentry_error(action: &str, error: &io::Error) -> io::Error {
Ok(mount_bind( io::Error::new(error.kind(), format!("{action}: {error}"))
proc_fd(source.as_raw_fd()),
proc_fd(target.as_raw_fd()),
)?)
} }
fn descriptor_remount_read_only(target: &File) -> io::Result<()> { fn cleanup_staged(error: io::Error, label: &Path) -> io::Error {
let result = mount_remount( combine_cleanup(
proc_fd(target.as_raw_fd()), 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::BIND
| MountFlags::RDONLY | MountFlags::RDONLY
| MountFlags::NOSUID | MountFlags::NOSUID
| MountFlags::NODEV | MountFlags::NODEV
| MountFlags::NOEXEC, | MountFlags::NOEXEC,
"", "",
); )
if result.is_err() { .map_err(Into::into)
let _ = unmount(proc_fd(target.as_raw_fd()), UnmountFlags::DETACH);
}
Ok(result?)
} }
fn descriptor_unmount(target: &File) -> io::Result<()> { fn require_empty_regular_file(file: &File) -> io::Result<()> {
Ok(unmount(proc_fd(target.as_raw_fd()), UnmountFlags::DETACH)?) 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 { fn require_regular_file(file: &File) -> io::Result<()> {
PathBuf::from(format!("/proc/self/fd/{fd}")) 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"))
}
} }

View file

@ -11,6 +11,7 @@ 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-ignored-workspace = { version = "=0.0.0", path = "../lazaret-ignored-workspace" }
lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" } lazaret-linux = { version = "=0.0.0", path = "../lazaret-linux" }
lazaret-state-anchor = { version = "=0.0.0", path = "../lazaret-state-anchor" } lazaret-state-anchor = { version = "=0.0.0", path = "../lazaret-state-anchor" }
oci-spec.workspace = true oci-spec.workspace = true

View file

@ -7,6 +7,13 @@ fn main() {
} }
return; 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) = if let Some(result) =
lazaret_runtime_supervisor::supervision::dispatch_private_runtime_role(&arguments) lazaret_runtime_supervisor::supervision::dispatch_private_runtime_role(&arguments)
{ {

View file

@ -625,6 +625,25 @@ pub fn production_sandbox_layout() -> Result<SandboxLayoutV1, SandboxBuildError>
/// Refuses changed host authority, namespace mismatch, invalid paths, or encoding failures. /// Refuses changed host authority, namespace mismatch, invalid paths, or encoding failures.
pub fn build_sandbox_bundle( pub fn build_sandbox_bundle(
request: SandboxBuildRequest<'_>, 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> { ) -> Result<SandboxBundle, SandboxBuildError> {
require_network_namespace(request.expected_network_namespace)?; require_network_namespace(request.expected_network_namespace)?;
request request
@ -648,6 +667,7 @@ pub fn build_sandbox_bundle(
request.configuration, request.configuration,
request.terminal.as_ref(), request.terminal.as_ref(),
&paths.control_sources, &paths.control_sources,
workspace_source,
)?; )?;
let process = build_process( let process = build_process(
request.configuration, request.configuration,
@ -873,10 +893,11 @@ fn build_mounts(
configuration: &ResolvedDomainConfiguration, configuration: &ResolvedDomainConfiguration,
terminal: Option<&StagedHostTerminal>, terminal: Option<&StagedHostTerminal>,
control_sources: &Path, control_sources: &Path,
workspace_source: PathBuf,
) -> Result<Vec<Mount>, SandboxBuildError> { ) -> Result<Vec<Mount>, SandboxBuildError> {
let mut mounts = standard_mounts()?; let mut mounts = standard_mounts()?;
mounts.push(bind_mount( mounts.push(bind_mount(
host_path(configuration.workspace().canonical_label())?, workspace_source,
PathBuf::from(WORKSPACE_DESTINATION), PathBuf::from(WORKSPACE_DESTINATION),
false, false,
false, false,

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,7 @@ use lazaret_runtime_supervisor::sandbox::{
MAILBOX_REQUESTS_DESTINATION, MAILBOX_RESULTS_DESTINATION, NETWORK_NAMESPACE_PATH, RootProcess, MAILBOX_REQUESTS_DESTINATION, MAILBOX_RESULTS_DESTINATION, NETWORK_NAMESPACE_PATH, RootProcess,
SANDBOX_INIT_DESTINATION, SandboxBuildErrorKind, SandboxBuildRequest, SandboxBundle, SANDBOX_INIT_DESTINATION, SandboxBuildErrorKind, SandboxBuildRequest, SandboxBundle,
TERMINFO_ROOT_DESTINATION, WORKSPACE_DESTINATION, build_sandbox_bundle, 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 serde_json::Value;
use tempfile::TempDir; 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) { fn assert_runtime_command(bundle: &SandboxBundle, fixture: &Fixture) {
assert_eq!(bundle.command().program(), OsStr::new("runsc")); assert_eq!(bundle.command().program(), OsStr::new("runsc"));
let runtime_domain = String::from_utf8( let runtime_domain = String::from_utf8(