diff --git a/crates/devguard/examples/fixture_authority.rs b/crates/devguard/examples/fixture_authority.rs index cbfee6f..206ce93 100644 --- a/crates/devguard/examples/fixture_authority.rs +++ b/crates/devguard/examples/fixture_authority.rs @@ -6,9 +6,11 @@ //! Without native host evidence (Linux before DG-LINUX) DevGuard's fixture cannot register, //! so it serves the authority without evidence and its bootstrap `dev-cli` consumer instead. //! -//! It prints one JSON line with the settings (none is a secret: the secret stays in its file), -//! then answers each `instances` line on standard input with a JSON line listing the -//! consumer's registered instances, and stops at the end of its input. Built only as an +//! It prints one JSON line with the settings (none is a secret: the secret stays in its file) +//! once the authority admits, then answers each `instances` line on standard input with a JSON +//! line listing the consumer's registered instances, and each `charged` line with one listing +//! the attempts that still hold resources and the budget they hold (CSRG-U3). It stops at the +//! end of its input. Built only as an //! example, from this crate's development dependencies; no product links it. use std::io::{BufRead, Write}; @@ -38,6 +40,7 @@ fn main() { for line in std::io::stdin().lock().lines() { match line.unwrap().trim() { "instances" => writeln!(out, "{}", fixture.instances()).unwrap(), + "charged" => writeln!(out, "{}", fixture.charged()).unwrap(), other => panic!("unknown command {other:?}"), } out.flush().unwrap(); @@ -71,6 +74,10 @@ impl Fixture { host.consumers.insert("codespace".into(), consumer); }) .unwrap(); + // Its synthetic host readings open admission after two samples. + authority + .wait_until_admitting(std::time::Duration::from_secs(10)) + .unwrap(); Self { settings: json!({ "socket": authority.socket(), @@ -123,6 +130,24 @@ impl Fixture { json!(instances) } + /// The attempts that still hold resources, and the budget they hold. + fn charged(&self) -> serde_json::Value { + #[cfg(target_os = "macos")] + { + let attempts: Vec<_> = self + .authority + .attempts() + .unwrap() + .into_iter() + .map(|record| json!({"attempt_id": record.key.attempt_id, "phase": record.phase})) + .collect(); + let committed = self.authority.committed().unwrap(); + json!({"attempts": attempts, "committed": committed}) + } + #[cfg(not(target_os = "macos"))] + json!({"attempts": [], "committed": {"cpu_milli": 0, "memory_bytes": 0, "tasks": 0}}) + } + fn stop(self) { #[cfg(target_os = "macos")] self.authority.stop().unwrap(); diff --git a/crates/devguard/src/admission.rs b/crates/devguard/src/admission.rs new file mode 100644 index 0000000..0b3d757 --- /dev/null +++ b/crates/devguard/src/admission.rs @@ -0,0 +1,1035 @@ +//! Admission of one attempt by the registered execution owner (CSRG-U3). +//! +//! DevGuard keeps attempts for the instance registered in the same session, so each request +//! here is one bounded session: the owner's registration (see [`Owner::register`]), then one +//! `Admit`, `Lookup` or `Cancel` of the attempt, then close. Nothing here launches anything: +//! `BeginLaunch` and its permit are U4's. +//! +//! An [`Answer`] says only what the session established: +//! +//! - [`Answer::NotSent`]: the session ended before the request was written, so nothing was asked. +//! - [`Answer::Refused`]: DevGuard answered the request with an error. Each of its requests is +//! one journal transaction that commits only before a record is answered, so an error answer +//! means the request changed nothing. +//! - [`Answer::Attempt`]: DevGuard answered with this owner's record of this attempt, for this +//! meaning. +//! - [`Answer::Unknown`]: the request may have reached DevGuard and no usable answer came back: +//! a timeout, a lost reply, an EOF, an answer for another attempt, owner or meaning, or +//! `resource_control_unavailable`, the code DevGuard's client also gives for every transport +//! failure. Whether the attempt exists, or changed, is not known. +//! +//! No answer keeps DevGuard's messages. + +use std::collections::{BTreeMap, BTreeSet}; + +use devguard_contract as contract; +use devguard_contract::{ + AdmissionRequest as WireAdmission, AttemptKey, AttemptRecord, Compatibility, ErrorCode as Code, + ExecutionMeaning, ResourceIntent, PROTOCOL_VERSION, +}; + +use crate::registration::{Owner, Registration}; +use crate::{effective_uid, ErrorCode}; + +/// The resource profile DevGuard admits interactive executions under. +pub const PROFILE: &str = "interactive"; + +/// How long DevGuard holds a prepared attempt that is not launched. +pub const PREPARED_TTL_MS: u64 = contract::PREPARED_TTL_MS; + +/// How strongly a resource is controlled, weakest first. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Level { + Accounted, + Cooperative, + Kernel, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Levels { + pub cpu: Level, + pub memory: Level, + pub pids: Level, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Quantities { + pub cpu_milli: u64, + pub memory_bytes: u64, + pub tasks: u64, +} + +/// What an execution asks for, and the weakest control it accepts for each resource. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResourceRequest { + pub requested: Quantities, + pub minimum: Levels, +} + +/// What an execution means: everything its digest covers. DevGuard records only the digest. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Meaning { + /// What is executed, as the owner names it. + pub executable: String, + /// Where it runs, as the owner names it. + pub cwd: String, + pub argv: Vec, + /// The environment the owner gives it. + pub environment: BTreeMap, + pub tty: bool, + pub timeout_ms: u64, + pub resources: ResourceRequest, +} + +impl Meaning { + /// DevGuard's semantic digest (encoding version 1), or `None` for a meaning DevGuard does not + /// encode: an empty argv or program, a zero timeout or a zero quantity. + pub fn digest(&self) -> Option { + ExecutionMeaning { + executable_identity: self.executable.clone(), + cwd_identity: self.cwd.clone(), + argv: self.argv.clone(), + environment_changes: self.environment.clone(), + tty: self.tty, + timeout_ms: self.timeout_ms, + resources: intent(&self.resources), + } + .digest() + .ok() + } +} + +/// One attempt's admission: its identity under the owner's consumer, the digest of its meaning +/// and its resource request. The same value is presented for its lookup and cancellation, so +/// an answer for anything else is never taken for it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Admission { + pub attempt_id: String, + pub digest: String, + pub resources: ResourceRequest, +} + +/// An attempt's phase in DevGuard. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Phase { + Denied, + Prepared, + LaunchCommitted, + ScopeBound, + RunAuthorized, + Draining, + Suspect, + Released, + Cancelled, + Expired, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Method { + Accounting, + QosAndPriority, + CgroupV2, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScopeKind { + ObservedProcessGroup, + ContainedCgroup, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Planned { + pub level: Level, + pub method: Method, +} + +/// How DevGuard would control the attempt: what it supports for this request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Plan { + pub scope: ScopeKind, + pub cpu: Planned, + pub memory: Planned, + pub pids: Planned, +} + +/// What DevGuard holds for the attempt, and for how long it holds it unlaunched. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Reservation { + pub quantities: Quantities, + pub prepared_ttl_ms: u64, +} + +/// This owner's record of the attempt, without DevGuard's identities and digests. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Attempt { + pub phase: Phase, + /// Why it was denied, when denied. + pub denial: Option, + pub plan: Option, + pub reservation: Option, + /// Whether DevGuard reports resources applied to a launched scope. + pub applied: bool, + /// Whether DevGuard's record proves nothing was ever started for it. + pub known_not_started: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Answer { + /// The session ended before the request was written: nothing was asked. + NotSent(Registration), + /// DevGuard answered with this error, so the request changed nothing. + Refused(ErrorCode), + /// DevGuard answered with this owner's record of the attempt. + Attempt(Attempt), + /// The request may have reached DevGuard and no usable answer came back. + Unknown, +} + +impl Owner { + /// Ask DevGuard to admit the attempt. Admitting the same attempt again with the same + /// meaning returns its record; with another meaning, DevGuard refuses it. + pub fn admit(&self, admission: &Admission) -> Answer { + self.admit_with(admission, effective_uid(), self.pid()) + } + + /// DevGuard's record of the attempt. + pub fn lookup(&self, admission: &Admission) -> Answer { + self.attempt_request(admission, Request::Lookup, effective_uid(), self.pid()) + } + + /// Cancel the attempt. DevGuard cancels a prepared attempt, fences a launched one and leaves + /// a settled one as it is, and answers its record. + pub fn cancel(&self, admission: &Admission) -> Answer { + self.attempt_request(admission, Request::Cancel, effective_uid(), self.pid()) + } + + pub(crate) fn admit_with(&self, admission: &Admission, uid: u32, pid: u32) -> Answer { + self.attempt_request(admission, Request::Admit, uid, pid) + } + + fn attempt_request( + &self, + admission: &Admission, + request: Request, + authority_uid: u32, + owner_pid: u32, + ) -> Answer { + self.attempt_request_after(admission, request, authority_uid, owner_pid, &|| {}) + } + + /// [`Self::attempt_request`], calling `registered` between the registration and the request. + fn attempt_request_after( + &self, + admission: &Admission, + request: Request, + authority_uid: u32, + owner_pid: u32, + registered: &dyn Fn(), + ) -> Answer { + let Some(wire) = self.wire(admission) else { + return Answer::Refused(ErrorCode::InvalidRequest); + }; + let Ok(fingerprint) = wire.fingerprint() else { + return Answer::Refused(ErrorCode::InvalidRequest); + }; + let (mut client, _) = + match self.open_with(authority_uid, admission_compatibility(), owner_pid) { + Ok(opened) => opened, + Err(registration) => return Answer::NotSent(registration), + }; + registered(); + let key = wire.key.clone(); + let answered = match request { + Request::Admit => client.admit(wire), + Request::Lookup => client.lookup(key.clone()), + Request::Cancel => client.cancel(key.clone()), + }; + match answered { + Ok(record) => self.attempt(&record, &key, &fingerprint), + Err(error) => answered_error(error.code), + } + } + + fn wire(&self, admission: &Admission) -> Option { + let wire = WireAdmission { + key: AttemptKey { + consumer_id: self.settings().consumer.clone(), + consumer_generation: self.settings().generation.clone(), + attempt_id: admission.attempt_id.clone(), + }, + execution_digest: admission.digest.clone(), + intent: intent(&admission.resources), + }; + wire.validate().ok().map(|()| wire) + } + + /// The record, when it is this owner's record of this attempt for this meaning. + fn attempt(&self, record: &AttemptRecord, key: &AttemptKey, fingerprint: &str) -> Answer { + let ours = self + .registered_identity() + .is_some_and(|identity| identity == record.owner); + if record.key != *key || record.request_fingerprint != fingerprint || !ours { + return Answer::Unknown; + } + Answer::Attempt(Attempt { + phase: phase(record.phase), + denial: record.denial.map(ErrorCode::from), + plan: record.plan.as_ref().map(plan), + reservation: record.reservation.as_ref().map(|reservation| Reservation { + quantities: quantities(reservation.quantities), + prepared_ttl_ms: reservation + .prepare_deadline_ms + .saturating_sub(reservation.prepared_at.monotonic_ms), + }), + applied: record.applied.is_some(), + known_not_started: record.known_not_started(), + }) + } +} + +#[derive(Debug, Clone, Copy)] +enum Request { + Admit, + Lookup, + Cancel, +} + +/// An error answer is definite, except DevGuard's client's own code for a transport failure. +fn answered_error(code: Code) -> Answer { + match code { + Code::ResourceControlUnavailable => Answer::Unknown, + code => Answer::Refused(code.into()), + } +} + +/// Admission sessions need what registration needs, and durable admission. +pub(crate) fn admission_compatibility() -> Compatibility { + Compatibility { + minimum_protocol: PROTOCOL_VERSION, + maximum_protocol: PROTOCOL_VERSION, + required: BTreeSet::from([ + contract::Capability::StaticControlReservations, + contract::Capability::DurableAdmission, + ]), + } +} + +fn intent(resources: &ResourceRequest) -> ResourceIntent { + ResourceIntent { + profile: PROFILE.into(), + requested: contract::Budget { + cpu_milli: resources.requested.cpu_milli, + memory_bytes: resources.requested.memory_bytes, + tasks: resources.requested.tasks, + }, + minimum: contract::ResourceLevels { + cpu: level_to(resources.minimum.cpu), + memory: level_to(resources.minimum.memory), + pids: level_to(resources.minimum.pids), + }, + } +} + +fn level_to(level: Level) -> contract::EnforcementLevel { + match level { + Level::Accounted => contract::EnforcementLevel::Accounted, + Level::Cooperative => contract::EnforcementLevel::Cooperative, + Level::Kernel => contract::EnforcementLevel::Kernel, + } +} + +fn level(level: contract::EnforcementLevel) -> Level { + match level { + contract::EnforcementLevel::Accounted => Level::Accounted, + contract::EnforcementLevel::Cooperative => Level::Cooperative, + contract::EnforcementLevel::Kernel => Level::Kernel, + } +} + +fn quantities(budget: contract::Budget) -> Quantities { + Quantities { + cpu_milli: budget.cpu_milli, + memory_bytes: budget.memory_bytes, + tasks: budget.tasks, + } +} + +fn planned(resource: &contract::PlannedResource) -> Planned { + Planned { + level: level(resource.level), + method: match resource.method { + contract::ControlMethod::Accounting => Method::Accounting, + contract::ControlMethod::QosAndPriority => Method::QosAndPriority, + contract::ControlMethod::CgroupV2 => Method::CgroupV2, + }, + } +} + +fn plan(plan: &contract::ExecutionPlan) -> Plan { + Plan { + scope: match plan.scope_kind { + contract::ScopeKind::ObservedProcessGroup => ScopeKind::ObservedProcessGroup, + contract::ScopeKind::ContainedCgroup => ScopeKind::ContainedCgroup, + }, + cpu: planned(&plan.cpu), + memory: planned(&plan.memory), + pids: planned(&plan.pids), + } +} + +fn phase(phase: contract::AttemptPhase) -> Phase { + match phase { + contract::AttemptPhase::Denied => Phase::Denied, + contract::AttemptPhase::Prepared => Phase::Prepared, + contract::AttemptPhase::LaunchCommitted => Phase::LaunchCommitted, + contract::AttemptPhase::ScopeBound => Phase::ScopeBound, + contract::AttemptPhase::RunAuthorized => Phase::RunAuthorized, + contract::AttemptPhase::Draining => Phase::Draining, + contract::AttemptPhase::Suspect => Phase::Suspect, + contract::AttemptPhase::Released => Phase::Released, + contract::AttemptPhase::Cancelled => Phase::Cancelled, + contract::AttemptPhase::Expired => Phase::Expired, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registration::RegistrationState; + use crate::tests::{private_file, short_directory, CODES}; + use crate::{OwnerCredential, OwnerSettings}; + use devguard_client::framing::{read_frame, write_frame}; + use devguard_client::protocol::{ + Frame, Hello, PeerIdentity, Request as Wire, Response, ServiceStatus, SessionRole, + WireError, FRAME_DEADLINE_MS, MAX_FRAME_BYTES, MAX_SESSIONS, + }; + use devguard_contract::{InstanceIdentity, ProcessIdentity}; + use std::os::unix::net::{UnixListener, UnixStream}; + use std::path::Path; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::Arc; + use std::time::Duration; + + pub(crate) fn request() -> ResourceRequest { + ResourceRequest { + // Small enough for the work capacity of a small host (under 500 m CPU on a + // three-core CI runner). + requested: Quantities { + cpu_milli: 100, + memory_bytes: 64 << 20, + tasks: 8, + }, + minimum: Levels { + cpu: Level::Accounted, + memory: Level::Accounted, + pids: Level::Accounted, + }, + } + } + + pub(crate) fn meaning(argv: &[&str]) -> Meaning { + Meaning { + executable: format!("argv0:{}", argv[0]), + cwd: "workspace:demo".into(), + argv: argv.iter().map(|part| part.to_string()).collect(), + environment: BTreeMap::from([("LANG".into(), "C".into())]), + tty: false, + timeout_ms: 30_000, + resources: request(), + } + } + + pub(crate) fn admission(attempt_id: &str, argv: &[&str]) -> Admission { + Admission { + attempt_id: attempt_id.into(), + digest: meaning(argv).digest().unwrap(), + resources: request(), + } + } + + #[test] + fn the_digest_covers_every_part_of_the_meaning() { + let base = meaning(&["/bin/echo", "a"]); + let digest = base.digest().unwrap(); + assert_eq!(digest.len(), 64); + assert_eq!(base.digest().unwrap(), digest); + let mut changed = Vec::new(); + let mut each = base.clone(); + each.executable = "argv0:/bin/cat".into(); + changed.push(each); + let mut each = base.clone(); + each.cwd = "workspace:other".into(); + changed.push(each); + let mut each = base.clone(); + each.argv.push("b".into()); + changed.push(each); + let mut each = base.clone(); + each.environment.insert("X".into(), "1".into()); + changed.push(each); + let mut each = base.clone(); + each.tty = true; + changed.push(each); + let mut each = base.clone(); + each.timeout_ms += 1; + changed.push(each); + let mut each = base.clone(); + each.resources.requested.tasks += 1; + changed.push(each); + let mut each = base.clone(); + each.resources.minimum.cpu = Level::Kernel; + changed.push(each); + for meaning in changed { + assert_ne!(meaning.digest().unwrap(), digest, "{meaning:?}"); + } + for broken in [ + Meaning { + argv: Vec::new(), + ..base.clone() + }, + Meaning { + timeout_ms: 0, + ..base.clone() + }, + Meaning { + resources: ResourceRequest { + requested: Quantities { + tasks: 0, + ..request().requested + }, + ..request() + }, + ..base.clone() + }, + ] { + assert_eq!(broken.digest(), None, "{broken:?}"); + } + } + + #[test] + fn only_the_transport_code_is_unknown() { + for code in CODES { + let expected = match code { + Code::ResourceControlUnavailable => Answer::Unknown, + code => Answer::Refused(code.into()), + }; + assert_eq!(answered_error(code), expected, "{code:?}"); + } + } + + // A scripted authority: replies, losses and delays a real one does not give at will. + + /// What the scripted authority does with the attempt request of session `n` (from 0). + enum Reply { + /// Answer this. + With(Box), + /// Read the request and close the connection: a lost reply. + Close, + /// Read the request and answer nothing until the client gives up. + Silent, + } + + type Script = Arc Reply + Send + Sync>; + + struct Scripted { + stop: Arc, + requests: Arc, + worker: Option>, + } + + impl Drop for Scripted { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } + } + + fn me() -> PeerIdentity { + PeerIdentity { + uid: effective_uid(), + pid: std::process::id(), + } + } + + fn instance(instance_id: &str) -> InstanceIdentity { + InstanceIdentity { + instance_id: instance_id.to_owned(), + process: ProcessIdentity { + boot_id: "boot".into(), + pid: std::process::id(), + start_ticks: 7, + }, + } + } + + fn scripted(socket: &Path, script: Script) -> Scripted { + let listener = UnixListener::bind(socket).unwrap(); + listener.set_nonblocking(true).unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let requests = Arc::new(AtomicUsize::new(0)); + let (signal, count) = (stop.clone(), requests.clone()); + let worker = std::thread::spawn(move || { + let mut session = 0; + while !signal.load(Ordering::Relaxed) { + let Ok((mut stream, _)) = listener.accept() else { + std::thread::sleep(Duration::from_millis(1)); + continue; + }; + serve(&mut stream, &script, session, &count); + session += 1; + } + }); + Scripted { + stop, + requests, + worker: Some(worker), + } + } + + fn serve(stream: &mut UnixStream, script: &Script, session: usize, requests: &AtomicUsize) { + let deadline = Duration::from_millis(FRAME_DEADLINE_MS); + let mut registered = None; + while let Ok(request) = read_frame::>(stream, deadline) { + let body = match &request.body { + Wire::Hello { .. } => Response::Hello(Hello { + protocol: PROTOCOL_VERSION, + authority: me(), + caller: me(), + capabilities: admission_compatibility().required, + max_frame_bytes: MAX_FRAME_BYTES, + frame_deadline_ms: FRAME_DEADLINE_MS, + max_sessions: MAX_SESSIONS, + }), + Wire::Authenticate { .. } => Response::Authenticated { + role: SessionRole::ControlService, + }, + Wire::Status => Response::Status(ServiceStatus { + storage_validated: true, + registration_ready: true, + execution_ready: true, + reason: "a reason that is never kept".into(), + configuration_fingerprint: "fingerprint".into(), + }), + Wire::Register { instance_id } => { + let identity = instance(instance_id); + registered = Some(identity.clone()); + Response::Registered { instance: identity } + } + other => { + requests.fetch_add(1, Ordering::SeqCst); + match script(session, other, registered.as_ref().unwrap()) { + Reply::With(response) => *response, + Reply::Close => return, + Reply::Silent => { + std::thread::sleep(Duration::from_millis(3 * FRAME_DEADLINE_MS)); + return; + } + } + } + }; + let reply = Frame { + version: request.version, + request_id: request.request_id, + body, + }; + if write_frame(stream, &reply, deadline).is_err() { + return; + } + } + } + + fn scripted_owner(dir: &Path, socket: &Path) -> Owner { + let secret = "5ec2e7d0c0de".repeat(6); + let credential = dir.join("scripted.secret"); + if !credential.exists() { + private_file(dir, "scripted.secret", &secret.as_bytes()[..64]); + } + Owner::new( + OwnerSettings { + socket: socket.to_owned(), + consumer: "codespace".into(), + generation: "g1".into(), + }, + OwnerCredential::File(credential), + ) + } + + /// The record DevGuard keeps when it admits `request` for `owner`. + fn record( + request: &Wire, + owner: &InstanceIdentity, + phase: contract::AttemptPhase, + ) -> AttemptRecord { + let (key, fingerprint) = match request { + Wire::Admit { request } => (request.key.clone(), request.fingerprint().unwrap()), + _ => unreachable!("records are scripted for admissions"), + }; + AttemptRecord { + key, + request_fingerprint: fingerprint, + owner: owner.clone(), + policy_revision: "revision".into(), + phase, + reservation: None, + plan: None, + scope: None, + applied: None, + denial: None, + tracking_lost: false, + release_reason: None, + } + } + + fn admitted(owner: &Owner, admission: &Admission) -> Answer { + // DevGuard reads the credential within 250 ms (see `past_the_credential`). + crate::tests::past_the_credential( + |answer: &Answer| { + matches!(answer, Answer::NotSent(registration) + if registration.state == RegistrationState::CredentialUnavailable) + }, + || owner.admit(admission), + ) + } + + #[test] + fn a_session_that_ends_before_the_request_sends_nothing() { + let dir = short_directory(); + let socket = dir.path().join("closed.sock"); + // The endpoint closes each session at once. + let listener = UnixListener::bind(&socket).unwrap(); + let closer = std::thread::spawn(move || { + for _ in 0..5 { + drop(listener.accept()); + } + }); + let answer = admitted( + &scripted_owner(dir.path(), &socket), + &admission("attempt-1", &["/bin/echo"]), + ); + let Answer::NotSent(registration) = answer else { + panic!("{answer:?}"); + }; + assert_eq!(registration.state, RegistrationState::Unavailable); + drop(closer); + // Without a usable credential no session opens at all. + let unusable = Owner::new( + OwnerSettings { + socket: dir.path().join("absent.sock"), + consumer: "codespace".into(), + generation: "g1".into(), + }, + OwnerCredential::Unavailable, + ); + let Answer::NotSent(registration) = unusable.admit(&admission("attempt-1", &["/bin/echo"])) + else { + panic!("sent"); + }; + assert_eq!(registration.state, RegistrationState::CredentialUnavailable); + } + + #[test] + fn an_invalid_admission_is_refused_without_a_session() { + let dir = short_directory(); + let socket = dir.path().join("watched.sock"); + let authority = scripted(&socket, Arc::new(|_, _, _| Reply::Close)); + let owner = scripted_owner(dir.path(), &socket); + for invalid in [ + Admission { + attempt_id: "has space".into(), + ..admission("a", &["/bin/echo"]) + }, + Admission { + digest: "not-a-digest".into(), + ..admission("a", &["/bin/echo"]) + }, + ] { + assert_eq!( + owner.admit(&invalid), + Answer::Refused(ErrorCode::InvalidRequest) + ); + } + assert_eq!(authority.requests.load(Ordering::SeqCst), 0); + } + + #[test] + fn every_error_answer_keeps_only_its_code() { + let dir = short_directory(); + for code in CODES { + let socket = dir.path().join(format!("{code:?}.sock")); + let _authority = scripted( + &socket, + Arc::new(move |_, _, _| { + Reply::With(Box::new(Response::Error(WireError { + code, + message: "a message that is never kept".into(), + }))) + }), + ); + let owner = scripted_owner(dir.path(), &socket); + let answer = admitted(&owner, &admission("attempt-1", &["/bin/echo"])); + assert_eq!(answer, answered_error(code), "{code:?}"); + assert!(!format!("{answer:?}").contains("never kept")); + } + } + + #[test] + fn a_timeout_or_a_lost_reply_is_unknown_not_a_refusal() { + let dir = short_directory(); + for (name, silent) in [("silent", true), ("lost", false)] { + let socket = dir.path().join(format!("{name}.sock")); + let authority = scripted( + &socket, + Arc::new(move |_, _, _| if silent { Reply::Silent } else { Reply::Close }), + ); + let owner = scripted_owner(dir.path(), &socket); + let started = std::time::Instant::now(); + for request in [Request::Admit, Request::Lookup, Request::Cancel] { + let answer = crate::tests::past_the_credential( + |answer: &Answer| matches!(answer, Answer::NotSent(_)), + || { + owner.attempt_request( + &admission("attempt-1", &["/bin/echo"]), + request, + effective_uid(), + std::process::id(), + ) + }, + ); + assert_eq!(answer, Answer::Unknown, "{name} {request:?}"); + } + // The request reached the authority each time. + assert!(authority.requests.load(Ordering::SeqCst) >= 3); + assert!(started.elapsed() < Duration::from_secs(10)); + } + } + + #[test] + fn a_record_for_another_attempt_meaning_or_owner_is_unknown() { + let dir = short_directory(); + let socket = dir.path().join("records.sock"); + // Session 0 answers the attempt's own record; then one for another attempt, one for + // another meaning and one of another owner. + let _authority = scripted( + &socket, + Arc::new(|session, request, owner| { + let mut record = record(request, owner, contract::AttemptPhase::Prepared); + match session { + 0 => {} + 1 => record.key.attempt_id = "attempt-other".into(), + 2 => record.request_fingerprint = "0".repeat(64), + _ => record.owner.process.start_ticks += 1, + } + Reply::With(Box::new(Response::Attempt(record))) + }), + ); + let owner = scripted_owner(dir.path(), &socket); + let admission = admission("attempt-1", &["/bin/echo"]); + let Answer::Attempt(attempt) = admitted(&owner, &admission) else { + panic!("not admitted"); + }; + assert_eq!(attempt.phase, Phase::Prepared); + assert!(!attempt.applied); + for _ in 1..4 { + assert_eq!(admitted(&owner, &admission), Answer::Unknown); + } + } + + // DevGuard's fixture authority with native host evidence, as on macOS. + + #[cfg(target_os = "macos")] + mod native { + use super::*; + use crate::registration::tests::native::{provision, Provisioned}; + use devguard_contract::Budget; + + fn admitting() -> Provisioned { + let provisioned = provision(2); + provisioned + .authority + .wait_until_admitting(Duration::from_secs(10)) + .unwrap(); + provisioned + } + + fn charged(provisioned: &Provisioned) -> Budget { + provisioned.authority.committed().unwrap() + } + + #[test] + fn an_admitted_attempt_is_prepared_reserved_and_not_applied_then_cancelled() { + let provisioned = admitting(); + let owner = provisioned.owner(); + let admission = admission("cs-attempt-1", &["/bin/echo", "a"]); + let Answer::Attempt(attempt) = admitted(&owner, &admission) else { + panic!("not admitted"); + }; + assert_eq!(attempt.phase, Phase::Prepared, "{attempt:?}"); + assert_eq!(attempt.denial, None); + assert!(!attempt.applied && !attempt.known_not_started); + let reservation = attempt.reservation.unwrap(); + assert_eq!(reservation.quantities, request().requested); + assert_eq!(reservation.prepared_ttl_ms, contract::PREPARED_TTL_MS); + // What DevGuard supports on macOS, at least what was required. + let plan = attempt.plan.unwrap(); + assert_eq!(plan.scope, ScopeKind::ObservedProcessGroup); + assert_eq!( + (plan.cpu.level, plan.memory.level, plan.pids.level), + (Level::Cooperative, Level::Accounted, Level::Accounted) + ); + assert_eq!(plan.cpu.method, Method::QosAndPriority); + assert_eq!(charged(&provisioned).tasks, request().requested.tasks); + // Admitting it again returns the same record; looking it up too. + assert_eq!(admitted(&owner, &admission), Answer::Attempt(attempt)); + assert_eq!(owner.lookup(&admission), Answer::Attempt(attempt)); + let Answer::Attempt(cancelled) = owner.cancel(&admission) else { + panic!("not cancelled"); + }; + assert_eq!(cancelled.phase, Phase::Cancelled); + assert!(cancelled.known_not_started); + assert_eq!(charged(&provisioned), Budget::ZERO); + // Cancelling again keeps the settled record. + assert_eq!(owner.cancel(&admission), Answer::Attempt(cancelled)); + } + + #[test] + fn the_same_attempt_for_another_command_is_refused() { + let provisioned = admitting(); + let owner = provisioned.owner(); + let first = admission("cs-attempt-2", &["/bin/echo", "a"]); + assert!(matches!(admitted(&owner, &first), Answer::Attempt(_))); + let other = admission("cs-attempt-2", &["/bin/echo", "b"]); + assert_eq!( + admitted(&owner, &other), + Answer::Refused(ErrorCode::AttemptConflict) + ); + // Nor is it found or cancelled under the other meaning. + assert_eq!(owner.lookup(&other), Answer::Unknown); + assert_eq!(owner.cancel(&other), Answer::Unknown); + assert!( + matches!(owner.cancel(&first), Answer::Attempt(a) if a.phase == Phase::Cancelled) + ); + } + + #[test] + fn a_shortage_or_an_unsupported_minimum_is_denied_and_reserves_nothing() { + let provisioned = admitting(); + let owner = provisioned.owner(); + let capacity = provisioned.authority.work_capacity().unwrap(); + let mut shortage = admission("cs-short", &["/bin/echo"]); + shortage.resources.requested.memory_bytes = capacity.memory_bytes + 1; + shortage.digest = Meaning { + resources: shortage.resources, + ..meaning(&["/bin/echo"]) + } + .digest() + .unwrap(); + let mut kernel = admission("cs-kernel", &["/bin/echo"]); + kernel.resources.minimum.memory = Level::Kernel; + kernel.digest = Meaning { + resources: kernel.resources, + ..meaning(&["/bin/echo"]) + } + .digest() + .unwrap(); + for (admission, denial) in [ + (shortage, ErrorCode::ResourceUnavailable), + (kernel, ErrorCode::ResourcePolicyUnsupported), + ] { + let Answer::Attempt(attempt) = admitted(&owner, &admission) else { + panic!("no record"); + }; + assert_eq!( + (attempt.phase, attempt.denial, attempt.reservation), + (Phase::Denied, Some(denial), None) + ); + assert!(attempt.known_not_started); + } + assert_eq!(charged(&provisioned), Budget::ZERO); + } + + #[test] + fn a_timed_out_admission_may_still_have_reserved() { + let provisioned = admitting(); + let owner = provisioned.owner(); + let admission = admission("cs-slow", &["/bin/echo"]); + // Once the session is registered, the authority's lock is held past the client's + // deadline, so the admission waits behind it and the client gives up. + let holder = std::sync::Mutex::new(None); + let answer = crate::tests::past_the_credential( + |answer: &Answer| matches!(answer, Answer::NotSent(_)), + || { + owner.attempt_request_after( + &admission, + Request::Admit, + effective_uid(), + std::process::id(), + &|| { + let held = provisioned + .authority + .hold_authority(Duration::from_millis(900)); + *holder.lock().unwrap() = Some(held); + }, + ) + }, + ); + if let Some(held) = holder.lock().unwrap().take() { + held.join().unwrap(); + } + assert_eq!(answer, Answer::Unknown); + // DevGuard admitted it after the client gave up: the timeout was not a refusal. + let found = (0..20).find_map(|_| match owner.lookup(&admission) { + Answer::Attempt(attempt) => Some(attempt), + _ => { + std::thread::sleep(Duration::from_millis(50)); + None + } + }); + let attempt = found.expect("the timed-out admission was recorded"); + assert_eq!(attempt.phase, Phase::Prepared); + assert!( + matches!(owner.cancel(&admission), Answer::Attempt(a) if a.phase == Phase::Cancelled) + ); + } + + #[test] + fn an_unlaunched_attempt_expires_on_its_own() { + let provisioned = admitting(); + let owner = provisioned.owner(); + let admission = admission("cs-expiring", &["/bin/echo"]); + assert!( + matches!(admitted(&owner, &admission), Answer::Attempt(a) if a.phase == Phase::Prepared) + ); + std::thread::sleep(Duration::from_millis(contract::PREPARED_TTL_MS + 300)); + let Answer::Attempt(expired) = owner.lookup(&admission) else { + panic!("not found"); + }; + assert_eq!(expired.phase, Phase::Expired); + assert!(expired.known_not_started); + assert_eq!(charged(&provisioned), Budget::ZERO); + // Cancelling an expired attempt leaves it expired. + assert_eq!(owner.cancel(&admission), Answer::Attempt(expired)); + } + + #[test] + fn another_owner_cannot_see_or_cancel_the_attempt() { + let provisioned = admitting(); + let owner = provisioned.owner(); + let admission = admission("cs-mine", &["/bin/echo"]); + assert!(matches!(admitted(&owner, &admission), Answer::Attempt(_))); + // A second owner in this process is another instance of the same consumer. + let other = provisioned.owner(); + assert_eq!( + other.lookup(&admission), + Answer::Refused(ErrorCode::Unauthorized) + ); + assert_eq!( + other.cancel(&admission), + Answer::Refused(ErrorCode::Unauthorized) + ); + assert!( + matches!(owner.cancel(&admission), Answer::Attempt(a) if a.phase == Phase::Cancelled) + ); + } + } +} diff --git a/crates/devguard/src/lib.rs b/crates/devguard/src/lib.rs index 44f49d0..ccf3696 100644 --- a/crates/devguard/src/lib.rs +++ b/crates/devguard/src/lib.rs @@ -4,8 +4,9 @@ //! client: connect, `Hello`, `Authenticate` as an operator-provisioned consumer, then `Status`, //! and closes it (CSRG-U1). [`Owner::register`] registers the process that owns CodeSpace's //! executions in such a session (CSRG-U2), and [`handoff`] hands the consumer secret to the UDS -//! worker that owns them. Nothing here admits or launches anything. Every outcome is a -//! [`Status`] or a [`Registration`], never an error for the caller. +//! worker that owns them. [`admission`] admits, looks up and cancels one attempt of that owner +//! (CSRG-U3). Nothing here launches anything. Every outcome is a [`Status`], a [`Registration`] +//! or an [`admission::Answer`], never an error for the caller. //! //! DevGuard's transport bounds the session: connecting and each frame read or write have a //! 250 ms deadline, so a probe ends within about 1.75 s and a registration within about 2.25 s. @@ -21,6 +22,7 @@ //! from DevGuard's `connect_timeout`, which macOS cannot create close-on-exec atomically; //! CodeSpace's spawners keep it out of their children (#79). +pub mod admission; pub mod handoff; mod registration; diff --git a/crates/devguard/src/registration.rs b/crates/devguard/src/registration.rs index ac9aef5..62a5e37 100644 --- a/crates/devguard/src/registration.rs +++ b/crates/devguard/src/registration.rs @@ -128,6 +128,32 @@ impl Owner { &self.instance_id } + pub(crate) fn pid(&self) -> u32 { + self.pid + } + + /// The consumer this owner registers as. + pub fn consumer(&self) -> &str { + &self.settings.consumer + } + + /// The consumer's generation. + pub fn generation(&self) -> &str { + &self.settings.generation + } + + pub(crate) fn settings(&self) -> &OwnerSettings { + &self.settings + } + + /// The identity DevGuard registered for this owner, once it has. + pub(crate) fn registered_identity(&self) -> Option { + self.registered + .lock() + .ok() + .and_then(|registered| registered.clone()) + } + /// One bounded registration session. pub fn register(&self) -> Registration { self.register_with(effective_uid(), registration(), self.pid) @@ -139,23 +165,36 @@ impl Owner { compatibility: Compatibility, owner_pid: u32, ) -> Registration { + match self.open_with(authority_uid, compatibility, owner_pid) { + Ok((_, registration)) | Err(registration) => registration, + } + } + + /// A registered session, still open for one more request, or the registration that + /// stopped before it. Only a [`RegistrationState::Registered`] outcome comes with a client. + pub(crate) fn open_with( + &self, + authority_uid: u32, + compatibility: Compatibility, + owner_pid: u32, + ) -> Result<(Client, Registration), Registration> { // The credential is read first, so a local problem opens no session. let credential = match self.caller_credential() { Ok(credential) => credential, - Err(error) => return failed(Step::Credential, &error), + Err(error) => return Err(failed(Step::Credential, &error)), }; let mut client = match Client::connect(&self.settings.socket, authority_uid, compatibility) { Ok(client) => client, - Err(error) => return failed(Step::Connect, &error), + Err(error) => return Err(failed(Step::Connect, &error)), }; let role = match client.authenticate(credential) { Ok(role) => role, - Err(error) => return failed(Step::Authenticate, &error), + Err(error) => return Err(failed(Step::Authenticate, &error)), }; let service = match client.status() { Ok(status) => status, - Err(error) => return failed(Step::Status, &error), + Err(error) => return Err(failed(Step::Status, &error)), }; let registration_ready = service.registration_ready; let status = Status { @@ -170,36 +209,38 @@ impl Owner { pid, }; if role != SessionRole::ControlService { - return outcome(RegistrationState::RoleMismatch, None, None); + return Err(outcome(RegistrationState::RoleMismatch, None, None)); } if !registration_ready { - return outcome(RegistrationState::NotReady, None, None); + return Err(outcome(RegistrationState::NotReady, None, None)); } let instance = match client.register(self.instance_id.clone()) { Ok(instance) => instance, - Err(error) => return outcome(refusal(error.code), Some(error.code.into()), None), + Err(error) => return Err(outcome(refusal(error.code), Some(error.code.into()), None)), }; // DevGuard registers the session's peer. It must be this owner, under its own identity, // and every session must register the same identity. if instance.instance_id != self.instance_id || instance.process.pid != owner_pid { - return outcome(RegistrationState::OwnerMismatch, None, None); + return Err(outcome(RegistrationState::OwnerMismatch, None, None)); } let mut registered = match self.registered.lock() { Ok(registered) => registered, - Err(_) => return outcome(RegistrationState::OwnerMismatch, None, None), + Err(_) => return Err(outcome(RegistrationState::OwnerMismatch, None, None)), }; match registered.as_ref() { Some(first) if *first != instance => { - return outcome(RegistrationState::OwnerMismatch, None, None) + return Err(outcome(RegistrationState::OwnerMismatch, None, None)) } Some(_) => {} None => *registered = Some(instance.clone()), } - outcome( + drop(registered); + let registration = outcome( RegistrationState::Registered, None, Some(instance.process.pid), - ) + ); + Ok((client, registration)) } fn caller_credential(&self) -> Result { @@ -280,7 +321,7 @@ fn mint_instance_id() -> String { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use crate::tests::{past_the_credential, private_file, short_directory, Authority, CODES}; use crate::{Capability, Role}; @@ -782,7 +823,7 @@ mod tests { // DevGuard's fixture authority with native host evidence, as on macOS. #[cfg(target_os = "macos")] - mod native { + pub(crate) mod native { use super::*; use devguard_daemon::config::ConsumerConfig; use devguard_daemon::fixture::TestAuthority; @@ -794,14 +835,14 @@ mod tests { /// DevGuard's fixture authority with a `codespace` control-service consumer, as an /// operator provisions CodeSpace, beside its bootstrap `dev-cli` workload consumer. - pub(super) struct Provisioned { + pub(crate) struct Provisioned { _directory: tempfile::TempDir, - pub(super) authority: TestAuthority, - pub(super) settings: OwnerSettings, - pub(super) credential: PathBuf, + pub(crate) authority: TestAuthority, + pub(crate) settings: OwnerSettings, + pub(crate) credential: PathBuf, } - pub(super) fn provision(max_instances: u32) -> Provisioned { + pub(crate) fn provision(max_instances: u32) -> Provisioned { let directory = short_directory(); let paths = AuthorityPaths::fixture(directory.path()); let credential = paths.credentials().join("codespace.secret"); @@ -841,7 +882,7 @@ mod tests { } impl Provisioned { - pub(super) fn owner(&self) -> Owner { + pub(crate) fn owner(&self) -> Owner { Owner::new( self.settings.clone(), OwnerCredential::File(self.credential.clone()), diff --git a/crates/domain/src/error.rs b/crates/domain/src/error.rs index f5c520d..24be622 100644 --- a/crates/domain/src/error.rs +++ b/crates/domain/src/error.rs @@ -45,6 +45,19 @@ pub enum ErrorCode { /// The workspace requires resource participation that this CodeSpace cannot provide, so /// nothing was started. Not occupancy; an operator setting or support decision changes it. ResourcePolicyUnsupported, + /// The resource authority admitted the execution, but CodeSpace cannot launch it under the + /// authority yet, so the unstarted attempt was cancelled and nothing was started (CSRG-U3). + ManagedLaunchUnavailable, + /// The resource authority denied the execution for lack of resources, or its admission + /// expired before launch; nothing was started. A later request may be admitted. + ResourceUnavailable, + /// The resource authority could not be asked, or refused the request, so the execution was + /// not admitted and nothing was started. + ResourceAuthorityUnavailable, + /// The admission request may have reached the resource authority and no answer came back, + /// so whether it reserved resources is not known. Nothing was started; do not assume the + /// attempt does not exist. + AdmissionUnknown, /// Scheduler or store invariant failed. Not occupancy; do not retry as busy. Internal, } @@ -134,6 +147,20 @@ mod tests { assert_eq!(json, "\"INTENT_ALREADY_CLAIMED\""); let json = serde_json::to_string(&ErrorCode::WorkClosed).unwrap(); assert_eq!(json, "\"WORK_CLOSED\""); + for (code, name) in [ + ( + ErrorCode::ManagedLaunchUnavailable, + "MANAGED_LAUNCH_UNAVAILABLE", + ), + (ErrorCode::ResourceUnavailable, "RESOURCE_UNAVAILABLE"), + ( + ErrorCode::ResourceAuthorityUnavailable, + "RESOURCE_AUTHORITY_UNAVAILABLE", + ), + (ErrorCode::AdmissionUnknown, "ADMISSION_UNKNOWN"), + ] { + assert_eq!(serde_json::to_string(&code).unwrap(), format!("\"{name}\"")); + } let json = serde_json::to_string(&ErrorCode::ApprovalRequired).unwrap(); assert_eq!(json, "\"APPROVAL_REQUIRED\""); let json = serde_json::to_string(&ErrorCode::ApprovalNotFound).unwrap(); diff --git a/crates/policy/src/lib.rs b/crates/policy/src/lib.rs index b8bef42..99550da 100644 --- a/crates/policy/src/lib.rs +++ b/crates/policy/src/lib.rs @@ -61,7 +61,8 @@ pub enum Participation { #[default] Off, /// Every new execution must be admitted and launched through the resource authority. - /// CodeSpace cannot do that yet, so every new execution is refused; none runs without it. + /// CodeSpace admits it (CSRG-U3) but cannot launch it there yet, so none runs; none runs + /// without the authority either. Required, } @@ -72,11 +73,76 @@ pub enum Participation { pub struct Resources { #[serde(default)] pub participation: Participation, + /// What each execution asks the authority for, with `required`. Left out, it is + /// [`ResourceRequest::default`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request: Option, } impl Resources { pub fn is_off(&self) -> bool { - self.participation == Participation::Off + self.participation == Participation::Off && self.request.is_none() + } + + /// The request each execution makes. + pub fn request(&self) -> ResourceRequest { + self.request.unwrap_or_default() + } +} + +/// How strongly the authority controls a resource, weakest first. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EnforcementLevel { + /// Counted against the authority's capacity. + Accounted, + /// Steered by priority and QoS. + Cooperative, + /// Limited by the kernel. + Kernel, +} + +/// The weakest control an execution accepts for each resource. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResourceMinimum { + pub cpu: EnforcementLevel, + pub memory: EnforcementLevel, + pub pids: EnforcementLevel, +} + +impl Default for ResourceMinimum { + fn default() -> Self { + Self { + cpu: EnforcementLevel::Accounted, + memory: EnforcementLevel::Accounted, + pids: EnforcementLevel::Accounted, + } + } +} + +/// The quantities one execution asks the authority to reserve, and the weakest control it +/// accepts. Every quantity is positive. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResourceRequest { + pub cpu_milli: u64, + pub memory_bytes: u64, + pub tasks: u64, + #[serde(default)] + pub minimum: ResourceMinimum, +} + +impl Default for ResourceRequest { + /// A quarter CPU, 256 MiB and 32 tasks, each at least accounted: small enough for DevGuard + /// to admit on a small host, whose work capacity can be under half a CPU. + fn default() -> Self { + Self { + cpu_milli: 250, + memory_bytes: 256 << 20, + tasks: 32, + minimum: ResourceMinimum::default(), + } } } @@ -205,6 +271,13 @@ impl Registry { let environment_id = entry .environment .unwrap_or_else(|| DEFAULT_ENVIRONMENT_ID.to_string()); + if let Some(request) = entry.resources.request { + if request.cpu_milli == 0 || request.memory_bytes == 0 || request.tasks == 0 { + return Err(format!( + "workspace `{id}` resources.request quantities must be positive" + )); + } + } let environment = registry.environments.get(&environment_id).ok_or_else(|| { format!("workspace `{id}` references unknown environment `{environment_id}`") })?; @@ -366,6 +439,41 @@ mod tests { assert_eq!(decoded.resources, ws.resources); } + #[test] + fn a_resource_request_is_operator_config_with_positive_quantities() { + let json = r#"{"workspaces":{"demo":{"root":"/tmp/demo","resources":{"participation":"required"}}}}"#; + let registry = Registry::load_json(json).unwrap(); + assert_eq!( + registry.get("demo").unwrap().resources.request(), + ResourceRequest::default() + ); + let json = r#"{"workspaces":{"demo":{"root":"/tmp/demo","resources":{"participation":"required","request":{"cpu_milli":250,"memory_bytes":1048576,"tasks":4,"minimum":{"cpu":"cooperative","memory":"accounted","pids":"kernel"}}}}}}"#; + let registry = Registry::load_json(json).unwrap(); + let ws = registry.get("demo").unwrap(); + let request = ws.resources.request(); + assert_eq!( + (request.cpu_milli, request.memory_bytes, request.tasks), + (250, 1 << 20, 4) + ); + assert_eq!(request.minimum.cpu, EnforcementLevel::Cooperative); + assert_eq!(request.minimum.pids, EnforcementLevel::Kernel); + let decoded: Workspace = serde_json::from_value(serde_json::to_value(ws).unwrap()).unwrap(); + assert_eq!(decoded.resources, ws.resources); + for request in [ + r#"{"cpu_milli":0,"memory_bytes":1,"tasks":1}"#, + r#"{"cpu_milli":1,"memory_bytes":0,"tasks":1}"#, + r#"{"cpu_milli":1,"memory_bytes":1,"tasks":0}"#, + r#"{"cpu_milli":1,"memory_bytes":1}"#, + r#"{"cpu_milli":1,"memory_bytes":1,"tasks":1,"minimum":{"cpu":"strict","memory":"accounted","pids":"accounted"}}"#, + r#"{"cpu_milli":1,"memory_bytes":1,"tasks":1,"burst":1}"#, + ] { + let json = format!( + r#"{{"workspaces":{{"demo":{{"root":"/tmp/demo","resources":{{"participation":"required","request":{request}}}}}}}}}"# + ); + assert!(Registry::load_json(&json).is_err(), "{request}"); + } + } + #[test] fn unknown_resource_settings_fail_config_load() { for resources in [ diff --git a/crates/runner/src/admission.rs b/crates/runner/src/admission.rs new file mode 100644 index 0000000..0ca9a55 --- /dev/null +++ b/crates/runner/src/admission.rs @@ -0,0 +1,1349 @@ +//! Preparation of a governed execution by its owner (CSRG-U3). +//! +//! A workspace whose `resources.participation` is `required` runs nothing that the resource +//! authority has not admitted. The execution owner, which holds the process slots and the +//! registered identity, prepares each execution in this order: +//! +//! 1. a CodeSpace process slot, taken before anything is created ([`InProcessRunner::reserve_slot`]); +//! 2. the attempt's identity and the digest of its meaning; +//! 3. DevGuard's admission of that attempt, in a bounded session of the registered owner; +//! 4. a one-shot [`PreparedExecution`], which owns the meaning, the slot and the attempt. +//! +//! Admission state is never a process handle: nothing here creates a process. CodeSpace keeps +//! authorization, workspace policy, process slots and handles, PTY selection, output, timeout, +//! termination and reaping; DevGuard keeps admission, reservations and the attempt's durable +//! identity and accounting. +//! +//! Managed launch (`BeginLaunch`, its permit and carrier, the helper) is U4's. Until it exists +//! [`PreparedExecution::launch`] cannot launch: it cancels the unstarted attempt and answers +//! [`PreparationOutcome::LaunchUnavailable`]. Nothing is ever run outside the authority instead. +//! +//! Uncertainty stays conservative. A timeout, a lost reply or an EOF is not taken as a refusal +//! or as proof that no attempt exists: the attempt is looked up once, cancelled when the lookup +//! shows it prepared, and otherwise reported as unknown. An uncertain attempt is never +//! admitted again and never rebuilt into a [`PreparedExecution`]; DevGuard cancels nothing on +//! CodeSpace's behalf because a local task ended, and CodeSpace does not claim a release it did +//! not observe. +//! +//! Cancelling is the one settlement used, and only where its precondition holds: this owner +//! never asked DevGuard to launch the attempt, so a cancellation finds it prepared or settled. + +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use codespace_devguard as devguard; +use codespace_devguard::admission::{self as dg, Admission, Answer, Meaning}; +use codespace_domain::{ + ErrorBody, ErrorCode, ProcessId, ResourceAuthorityErrorCode, ResourceRegistrationState, +}; +use codespace_policy::{EnforcementLevel, Participation, ResourceRequest, Workspace}; +use serde::{Deserialize, Serialize}; + +use crate::process::{spawn_env, SlotReservation}; +use crate::registration::{error_code, registration_state}; +use crate::{InProcessRunner, RunnerCwd, RunnerExecRequest}; + +/// The authority that admits, finds and cancels an owner's attempts: DevGuard's, through the +/// registered owner. Each call is one bounded, blocking session. +pub trait AttemptAuthority: Send + Sync + 'static { + fn consumer(&self) -> &str; + fn generation(&self) -> &str; + fn admit(&self, admission: &Admission) -> Answer; + fn lookup(&self, admission: &Admission) -> Answer; + fn cancel(&self, admission: &Admission) -> Answer; +} + +impl AttemptAuthority for devguard::Owner { + fn consumer(&self) -> &str { + devguard::Owner::consumer(self) + } + fn generation(&self) -> &str { + devguard::Owner::generation(self) + } + fn admit(&self, admission: &Admission) -> Answer { + devguard::Owner::admit(self, admission) + } + fn lookup(&self, admission: &Admission) -> Answer { + devguard::Owner::lookup(self, admission) + } + fn cancel(&self, admission: &Admission) -> Answer { + devguard::Owner::cancel(self, admission) + } +} + +/// The identity of one attempt, which both CodeSpace and DevGuard hold: the consumer and +/// generation it is admitted under, its attempt ID (minted for it, never a transport request +/// ID), the CodeSpace process ID it would become, the workspace, whether it has a PTY, what it +/// asks for, and the digest of everything it means. A prepared attempt is bound to this +/// identity and to nothing else. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AttemptIdentity { + pub consumer: String, + pub generation: String, + pub attempt_id: String, + pub process_id: ProcessId, + pub workspace_id: String, + pub tty: bool, + pub resources: ResourceRequest, + pub digest: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Quantities { + pub cpu_milli: u64, + pub memory_bytes: u64, + pub tasks: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Levels { + pub cpu: EnforcementLevel, + pub memory: EnforcementLevel, + pub pids: EnforcementLevel, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ControlMethod { + Accounting, + QosAndPriority, + CgroupV2, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ScopeKind { + ObservedProcessGroup, + ContainedCgroup, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Control { + pub level: EnforcementLevel, + pub method: ControlMethod, +} + +/// How the authority would control the execution: what it supports for this request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Supported { + pub scope: ScopeKind, + pub cpu: Control, + pub memory: Control, + pub pids: Control, +} + +/// What the authority holds for the execution, and for how long it holds it unlaunched. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Reserved { + pub quantities: Quantities, + pub prepared_ttl_ms: u64, +} + +/// Whether resources were applied to a launched execution. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Applied { + /// Nothing was launched, so nothing was applied. Always so before managed launch (U4). + NotLaunched, +} + +/// The four stages of an admission, kept apart: what was requested and the weakest control +/// required, what the authority supports for it, what it reserved, and what was applied. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct AdmissionAccount { + pub requested: Quantities, + pub required: Levels, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub supported: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reserved: Option, + pub applied: Applied, +} + +/// What is known of the authority's attempt when the preparation ends. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AttemptState { + /// The authority was never asked: no attempt exists. + NotAsked, + /// The authority answered the admission with a refusal: it recorded no attempt. + NotRecorded, + /// Denied: nothing was reserved. + Denied, + /// Cancelled before launch: its reservation is returned. + Cancelled, + /// Expired unlaunched: its reservation is returned. + Expired, + /// The authority holds it in another phase. + Other, + /// Not known: it may be prepared, holding its reservation until the authority expires it. + /// Nothing was started for it. + Unknown, +} + +/// How a preparation ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "result", rename_all = "snake_case")] +pub enum PreparationOutcome { + /// Admitted and prepared; managed launch is not available yet, so the unstarted attempt + /// was cancelled. + LaunchUnavailable, + /// No CodeSpace process slot was free; the authority was not asked. + SlotUnavailable, + /// The authority denied it: `resource_unavailable` for a shortage, + /// `resource_policy_unsupported` when it cannot give the control required, or + /// `resource_control_unavailable`. + Denied { denial: ResourceAuthorityErrorCode }, + /// The owner's session ended before the admission was asked. + AuthorityUnavailable { + registration: ResourceRegistrationState, + #[serde(default, skip_serializing_if = "Option::is_none")] + error_code: Option, + }, + /// The authority refused the admission request. + Refused { + error_code: ResourceAuthorityErrorCode, + }, + /// The execution has no meaning the authority can record: an empty command, a zero + /// timeout or a zero quantity. + Invalid, + /// The prepared attempt's deadline passed before launch. + Expired, + /// The launch presented another command than the prepared one. + DigestMismatch, + /// Whether the authority admitted it is not known. + Unknown, +} + +/// The owner's report of one preparation. Nothing was started for it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ManagedPreparation { + pub attempt_id: String, + pub process_id: ProcessId, + pub outcome: PreparationOutcome, + pub attempt: AttemptState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account: Option, +} + +impl ManagedPreparation { + /// The tool error for this preparation. Every preparation ends in one, as none starts + /// anything (CSRG-U3). Only enumerations and identifiers are stated. + pub fn into_error_body(self, workspace_id: &str) -> ErrorBody { + let attempt = format!( + "attempt `{}` for process_id `{}`: {}; nothing was started", + self.attempt_id, + self.process_id.0, + wire_name(&self.attempt) + ); + let (code, what) = match self.outcome { + PreparationOutcome::LaunchUnavailable => ( + ErrorCode::ManagedLaunchUnavailable, + "the resource authority admitted it, but managed launch is not available yet, \ + so the unstarted attempt was cancelled" + .to_string(), + ), + PreparationOutcome::SlotUnavailable => ( + ErrorCode::WorkspaceBusy, + "live process limit reached; the resource authority was not asked".to_string(), + ), + PreparationOutcome::Denied { denial } => ( + match denial { + ResourceAuthorityErrorCode::ResourceUnavailable => { + ErrorCode::ResourceUnavailable + } + ResourceAuthorityErrorCode::ResourcePolicyUnsupported => { + ErrorCode::ResourcePolicyUnsupported + } + _ => ErrorCode::ResourceAuthorityUnavailable, + }, + format!("the resource authority denied it ({})", wire_name(&denial)), + ), + PreparationOutcome::AuthorityUnavailable { + registration, + error_code, + } => ( + ErrorCode::ResourceAuthorityUnavailable, + format!( + "the execution owner could not ask the resource authority (registration: {}{})", + wire_name(®istration), + error_code + .map(|code| format!(", {}", wire_name(&code))) + .unwrap_or_default() + ), + ), + PreparationOutcome::Refused { error_code } => ( + ErrorCode::ResourceAuthorityUnavailable, + format!( + "the resource authority refused the admission ({})", + wire_name(&error_code) + ), + ), + PreparationOutcome::Invalid => ( + ErrorCode::InvalidCommand, + "the execution has no meaning the resource authority can admit".to_string(), + ), + PreparationOutcome::Expired => ( + ErrorCode::ResourceUnavailable, + "the admission expired before launch".to_string(), + ), + PreparationOutcome::DigestMismatch => ( + ErrorCode::Internal, + "the launch did not match the prepared execution".to_string(), + ), + PreparationOutcome::Unknown => ( + ErrorCode::AdmissionUnknown, + "no answer came back from the resource authority, so whether it admitted the \ + execution is not known" + .to_string(), + ), + }; + ErrorBody::new( + code, + format!( + "workspace `{workspace_id}` requires resource participation: {what}; {attempt}" + ), + ) + } +} + +fn wire_name(value: &impl Serialize) -> String { + serde_json::to_value(value) + .ok() + .and_then(|name| name.as_str().map(str::to_owned)) + .unwrap_or_default() +} + +/// Prepares governed executions for the owner that holds both the process slots and the +/// registered identity. +#[derive(Clone)] +pub struct Preparer { + authority: Arc, + runner: InProcessRunner, + ttl_bound: Duration, +} + +impl Preparer { + pub fn new(authority: Arc, runner: InProcessRunner) -> Self { + Self { + authority, + runner, + ttl_bound: Duration::from_millis(devguard_prepared_ttl()), + } + } + + /// Hold prepared attempts for at most `bound` here, whatever the authority grants. + pub fn with_ttl_bound(mut self, bound: Duration) -> Self { + self.ttl_bound = bound; + self + } + + /// Prepare the execution `req` in `ws` as attempt `attempt_id`, or report why not. Only a + /// workspace that requires resource participation is prepared. + pub async fn prepare( + &self, + ws: &Workspace, + req: RunnerExecRequest, + attempt_id: String, + ) -> Result> { + let report = |outcome, attempt, account| { + Box::new(ManagedPreparation { + attempt_id: attempt_id.clone(), + process_id: req.process_id.clone(), + outcome, + attempt, + account, + }) + }; + if ws.resources.participation != Participation::Required { + return Err(report( + PreparationOutcome::Invalid, + AttemptState::NotAsked, + None, + )); + } + // The slot first: no admission is asked without one. + let slot = match self.runner.reserve_slot(&req.process_id.0) { + Ok(slot) => slot, + Err(_) => { + return Err(report( + PreparationOutcome::SlotUnavailable, + AttemptState::NotAsked, + None, + )) + } + }; + let resources = ws.resources.request(); + let Some(digest) = meaning(ws, &req).digest() else { + return Err(report( + PreparationOutcome::Invalid, + AttemptState::NotAsked, + None, + )); + }; + let identity = AttemptIdentity { + consumer: self.authority.consumer().to_owned(), + generation: self.authority.generation().to_owned(), + attempt_id: attempt_id.clone(), + process_id: req.process_id.clone(), + workspace_id: ws.id.0.clone(), + tty: req.tty, + resources, + digest: digest.clone(), + }; + let admission = Admission { + attempt_id: attempt_id.clone(), + digest, + resources: dg_request(&resources), + }; + let mut account = AdmissionAccount { + requested: requested(&resources), + required: required(&resources), + supported: None, + reserved: None, + applied: Applied::NotLaunched, + }; + let started = Instant::now(); + match call(&self.authority, &admission, Call::Admit).await { + Answer::Attempt(attempt) => { + account.supported = attempt.plan.map(supported); + account.reserved = attempt.reservation.map(reserved); + match attempt.phase { + dg::Phase::Prepared => { + let ttl = attempt + .reservation + .map(|reservation| Duration::from_millis(reservation.prepared_ttl_ms)) + .unwrap_or_default() + .min(self.ttl_bound); + Ok(PreparedExecution { + identity, + ws: ws.clone(), + request: req, + account, + deadline: started + ttl, + admission, + authority: self.authority.clone(), + slot: Some(slot), + settled: false, + }) + } + dg::Phase::Denied => Err(report( + PreparationOutcome::Denied { + denial: attempt + .denial + .map(error_code) + .unwrap_or(ResourceAuthorityErrorCode::ResourcePolicyUnsupported), + }, + AttemptState::Denied, + Some(account), + )), + // A fresh attempt is prepared or denied. Anything else is not understood: + // settle it as far as its precondition allows and report it unknown. + _ => { + let attempt = resolve(&self.authority, &admission).await; + Err(report(PreparationOutcome::Unknown, attempt, Some(account))) + } + } + } + Answer::Refused(code) => Err(report( + PreparationOutcome::Refused { + error_code: error_code(code), + }, + AttemptState::NotRecorded, + Some(account), + )), + Answer::NotSent(registration) => Err(report( + PreparationOutcome::AuthorityUnavailable { + registration: registration_state(registration.state), + error_code: registration.error_code.map(error_code), + }, + AttemptState::NotAsked, + Some(account), + )), + Answer::Unknown => { + let attempt = resolve(&self.authority, &admission).await; + Err(report(PreparationOutcome::Unknown, attempt, Some(account))) + } + } + } + + /// U3's whole path for a governed execution: prepare it, then present it for launch, which + /// cannot launch yet and cancels it. It runs as its own task, so a caller that stops + /// waiting leaves the preparation to settle rather than abandoning a prepared attempt. + pub async fn prepare_and_dispose( + &self, + ws: Workspace, + req: RunnerExecRequest, + attempt_id: String, + ) -> ManagedPreparation { + let preparer = self.clone(); + let (attempt, process_id) = (attempt_id.clone(), req.process_id.clone()); + let task = tokio::spawn(async move { + let launch = req.clone(); + match preparer.prepare(&ws, req, attempt_id).await { + Ok(prepared) => prepared.launch(&launch).await, + Err(report) => *report, + } + }); + task.await.unwrap_or(ManagedPreparation { + attempt_id: attempt, + process_id, + outcome: PreparationOutcome::Unknown, + attempt: AttemptState::Unknown, + account: None, + }) + } +} + +/// A prepared, admitted execution that has not started: one attempt, bound to its meaning, +/// holding its CodeSpace slot and its authority reservation until it is launched, cancelled +/// or dropped. It cannot be cloned, and launching or cancelling consumes it, so it is used +/// once. Its deadline is the authority's own, measured from before the admission was asked, +/// so it never outlives the reservation here. +/// +/// Dropping it unsettled cancels the attempt in the background; a local task ending is not +/// taken as the attempt's release. +pub struct PreparedExecution { + identity: AttemptIdentity, + ws: Workspace, + request: RunnerExecRequest, + account: AdmissionAccount, + deadline: Instant, + admission: Admission, + authority: Arc, + slot: Option, + settled: bool, +} + +impl std::fmt::Debug for PreparedExecution { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PreparedExecution") + .field("identity", &self.identity) + .field("account", &self.account) + .finish_non_exhaustive() + } +} + +impl PreparedExecution { + pub fn identity(&self) -> &AttemptIdentity { + &self.identity + } + + pub fn account(&self) -> &AdmissionAccount { + &self.account + } + + pub fn deadline(&self) -> Instant { + self.deadline + } + + /// The command it was prepared for. + pub fn request(&self) -> &RunnerExecRequest { + &self.request + } + + /// Present `request` for launch. Only the prepared command, before its deadline, could be + /// launched; managed launch is not available yet (U4), so the unstarted attempt is + /// cancelled in every case and nothing is started. + pub async fn launch(mut self, request: &RunnerExecRequest) -> ManagedPreparation { + let outcome = if meaning(&self.ws, request).digest().as_deref() + != Some(self.identity.digest.as_str()) + || request.process_id != self.identity.process_id + { + PreparationOutcome::DigestMismatch + } else if Instant::now() >= self.deadline { + PreparationOutcome::Expired + } else { + PreparationOutcome::LaunchUnavailable + }; + let attempt = self.settle().await; + self.report(outcome, attempt) + } + + /// Cancel the unstarted attempt and free its slot. + pub async fn cancel(mut self) -> AttemptState { + self.settle().await + } + + async fn settle(&mut self) -> AttemptState { + self.settled = true; + let attempt = cancelled(call(&self.authority, &self.admission, Call::Cancel).await); + self.slot = None; + attempt + } + + fn report(&self, outcome: PreparationOutcome, attempt: AttemptState) -> ManagedPreparation { + ManagedPreparation { + attempt_id: self.identity.attempt_id.clone(), + process_id: self.identity.process_id.clone(), + outcome, + attempt, + account: Some(self.account), + } + } +} + +impl Drop for PreparedExecution { + fn drop(&mut self) { + if self.settled { + return; + } + // Prepared and never launched: cancelling is sound. Without a runtime to run the + // session on, the attempt is left to expire at its deadline. + let (authority, admission) = (self.authority.clone(), self.admission.clone()); + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn_blocking(move || authority.cancel(&admission)); + } + } +} + +#[derive(Debug, Clone, Copy)] +enum Call { + Admit, + Lookup, + Cancel, +} + +/// One blocking session on its own thread. A thread that cannot finish is unknown. +async fn call(authority: &Arc, admission: &Admission, call: Call) -> Answer { + let (authority, admission) = (authority.clone(), admission.clone()); + tokio::task::spawn_blocking(move || match call { + Call::Admit => authority.admit(&admission), + Call::Lookup => authority.lookup(&admission), + Call::Cancel => authority.cancel(&admission), + }) + .await + .unwrap_or(Answer::Unknown) +} + +/// After an unknown answer: look the attempt up once, and cancel it when it is prepared. +async fn resolve(authority: &Arc, admission: &Admission) -> AttemptState { + match call(authority, admission, Call::Lookup).await { + Answer::Attempt(attempt) if attempt.phase == dg::Phase::Prepared => { + cancelled(call(authority, admission, Call::Cancel).await) + } + Answer::Attempt(attempt) => state(attempt.phase), + // Not found is not proof: the admission may still be on its way. + Answer::Refused(_) | Answer::NotSent(_) | Answer::Unknown => AttemptState::Unknown, + } +} + +fn cancelled(answer: Answer) -> AttemptState { + match answer { + Answer::Attempt(attempt) => state(attempt.phase), + Answer::Refused(_) | Answer::NotSent(_) | Answer::Unknown => AttemptState::Unknown, + } +} + +fn state(phase: dg::Phase) -> AttemptState { + match phase { + dg::Phase::Denied => AttemptState::Denied, + dg::Phase::Cancelled => AttemptState::Cancelled, + dg::Phase::Expired => AttemptState::Expired, + // Prepared after a cancellation, or any launched phase, is not what this owner asked for. + _ => AttemptState::Other, + } +} + +/// What the execution means, as its owner would run it: the command as given, the workspace it +/// runs in with its profile and network policy, the exact environment its child would get, its +/// PTY, its timeout and its resource request. The CodeSpace process ID is part of the +/// attempt's identity, not of its meaning. +pub(crate) fn meaning(ws: &Workspace, req: &RunnerExecRequest) -> Meaning { + let cwd = match req.cwd { + RunnerCwd::WorkspaceRoot => ws.root.clone(), + }; + let environment: BTreeMap = + spawn_env(&cwd, req, crate::linux_sandbox_available()) + .into_iter() + .collect(); + Meaning { + executable: req.argv.first().cloned().unwrap_or_default(), + cwd: format!( + "codespace-workspace:{}:{}:profile={}:network={}", + ws.id.0, + cwd.display(), + wire_name(&req.policy.workspace_profile), + wire_name(&req.policy.network), + ), + argv: req.argv.clone(), + environment, + tty: req.tty, + timeout_ms: req.timeout_ms, + resources: dg_request(&ws.resources.request()), + } +} + +/// DevGuard's own prepared lifetime, an upper bound for every preparation here. +fn devguard_prepared_ttl() -> u64 { + devguard::admission::PREPARED_TTL_MS +} + +fn dg_level(level: EnforcementLevel) -> dg::Level { + match level { + EnforcementLevel::Accounted => dg::Level::Accounted, + EnforcementLevel::Cooperative => dg::Level::Cooperative, + EnforcementLevel::Kernel => dg::Level::Kernel, + } +} + +fn level(level: dg::Level) -> EnforcementLevel { + match level { + dg::Level::Accounted => EnforcementLevel::Accounted, + dg::Level::Cooperative => EnforcementLevel::Cooperative, + dg::Level::Kernel => EnforcementLevel::Kernel, + } +} + +fn dg_request(request: &ResourceRequest) -> dg::ResourceRequest { + dg::ResourceRequest { + requested: dg::Quantities { + cpu_milli: request.cpu_milli, + memory_bytes: request.memory_bytes, + tasks: request.tasks, + }, + minimum: dg::Levels { + cpu: dg_level(request.minimum.cpu), + memory: dg_level(request.minimum.memory), + pids: dg_level(request.minimum.pids), + }, + } +} + +fn requested(request: &ResourceRequest) -> Quantities { + Quantities { + cpu_milli: request.cpu_milli, + memory_bytes: request.memory_bytes, + tasks: request.tasks, + } +} + +fn required(request: &ResourceRequest) -> Levels { + Levels { + cpu: request.minimum.cpu, + memory: request.minimum.memory, + pids: request.minimum.pids, + } +} + +fn control(planned: dg::Planned) -> Control { + Control { + level: level(planned.level), + method: match planned.method { + dg::Method::Accounting => ControlMethod::Accounting, + dg::Method::QosAndPriority => ControlMethod::QosAndPriority, + dg::Method::CgroupV2 => ControlMethod::CgroupV2, + }, + } +} + +fn supported(plan: dg::Plan) -> Supported { + Supported { + scope: match plan.scope { + dg::ScopeKind::ObservedProcessGroup => ScopeKind::ObservedProcessGroup, + dg::ScopeKind::ContainedCgroup => ScopeKind::ContainedCgroup, + }, + cpu: control(plan.cpu), + memory: control(plan.memory), + pids: control(plan.pids), + } +} + +fn reserved(reservation: dg::Reservation) -> Reserved { + Reserved { + quantities: Quantities { + cpu_milli: reservation.quantities.cpu_milli, + memory_bytes: reservation.quantities.memory_bytes, + tasks: reservation.quantities.tasks, + }, + prepared_ttl_ms: reservation.prepared_ttl_ms, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codespace_domain::{Profile, WorkspaceId}; + use std::collections::VecDeque; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Mutex; + + /// An authority whose answers the test sets, counting each call. + #[derive(Default)] + struct Fake { + admits: Mutex>, + lookups: Mutex>, + cancels: Mutex>, + admit_delay: Mutex, + calls: [AtomicUsize; 3], + seen: Mutex>, + } + + impl Fake { + fn admitting(answers: impl IntoIterator) -> Arc { + let fake = Self::default(); + fake.admits.lock().unwrap().extend(answers); + Arc::new(fake) + } + fn then_lookup(self: Arc, answer: Answer) -> Arc { + self.lookups.lock().unwrap().push_back(answer); + self + } + fn then_cancel(self: Arc, answer: Answer) -> Arc { + self.cancels.lock().unwrap().push_back(answer); + self + } + fn slow(self: Arc, delay: Duration) -> Arc { + *self.admit_delay.lock().unwrap() = delay; + self + } + fn count(&self, call: usize) -> usize { + self.calls[call].load(Ordering::SeqCst) + } + fn next(queue: &Mutex>, default: Answer) -> Answer { + queue.lock().unwrap().pop_front().unwrap_or(default) + } + } + + const ADMIT: usize = 0; + const LOOKUP: usize = 1; + const CANCEL: usize = 2; + + impl AttemptAuthority for Fake { + fn consumer(&self) -> &str { + "codespace" + } + fn generation(&self) -> &str { + "g1" + } + fn admit(&self, admission: &Admission) -> Answer { + self.calls[ADMIT].fetch_add(1, Ordering::SeqCst); + self.seen.lock().unwrap().push(admission.clone()); + std::thread::sleep(*self.admit_delay.lock().unwrap()); + Self::next(&self.admits, prepared()) + } + fn lookup(&self, admission: &Admission) -> Answer { + self.calls[LOOKUP].fetch_add(1, Ordering::SeqCst); + self.seen.lock().unwrap().push(admission.clone()); + Self::next(&self.lookups, Answer::Unknown) + } + fn cancel(&self, admission: &Admission) -> Answer { + self.calls[CANCEL].fetch_add(1, Ordering::SeqCst); + self.seen.lock().unwrap().push(admission.clone()); + Self::next(&self.cancels, attempt(dg::Phase::Cancelled, None)) + } + } + + fn attempt(phase: dg::Phase, denial: Option) -> Answer { + let prepared = phase == dg::Phase::Prepared; + Answer::Attempt(dg::Attempt { + phase, + denial, + plan: prepared.then_some(dg::Plan { + scope: dg::ScopeKind::ObservedProcessGroup, + cpu: dg::Planned { + level: dg::Level::Cooperative, + method: dg::Method::QosAndPriority, + }, + memory: dg::Planned { + level: dg::Level::Accounted, + method: dg::Method::Accounting, + }, + pids: dg::Planned { + level: dg::Level::Accounted, + method: dg::Method::Accounting, + }, + }), + reservation: prepared.then_some(dg::Reservation { + quantities: dg::Quantities { + cpu_milli: 250, + memory_bytes: 256 << 20, + tasks: 32, + }, + prepared_ttl_ms: 5_000, + }), + applied: false, + known_not_started: !prepared, + }) + } + + fn prepared() -> Answer { + attempt(dg::Phase::Prepared, None) + } + + fn governed(root: &std::path::Path) -> Workspace { + let mut ws = Workspace::new( + WorkspaceId("gov".into()), + root.to_path_buf(), + Profile::WorkspaceWrite, + ); + ws.resources.participation = Participation::Required; + ws + } + + fn request(argv: &[&str], process: &str) -> RunnerExecRequest { + RunnerExecRequest::for_host( + argv.iter().map(|part| part.to_string()).collect(), + ProcessId(process.into()), + Profile::WorkspaceWrite, + ) + } + + fn runner() -> InProcessRunner { + InProcessRunner::new(Arc::new(|_| {})) + } + + /// A command that would leave a mark if it ever ran. + fn touch(root: &std::path::Path, process: &str) -> RunnerExecRequest { + request( + &[ + "/usr/bin/touch", + &root.join("started").display().to_string(), + ], + process, + ) + } + + fn assert_nothing_started(root: &std::path::Path, runner: &InProcessRunner) { + assert!(!root.join("started").exists(), "a child ran"); + assert_eq!(runner.occupied_slots(), 0, "a slot is still held"); + } + + #[tokio::test] + async fn a_prepared_execution_holds_its_identity_slot_and_account_and_launches_nothing() { + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + let runner = runner(); + let fake = Fake::admitting([prepared()]); + let preparer = Preparer::new(fake.clone(), runner.clone()); + let req = touch(dir.path(), "proc-1"); + let prepared = preparer + .prepare(&ws, req.clone(), "cs-attempt-1".into()) + .await + .unwrap(); + let identity = prepared.identity().clone(); + assert_eq!( + ( + identity.consumer.as_str(), + identity.generation.as_str(), + identity.attempt_id.as_str(), + identity.process_id.0.as_str(), + identity.workspace_id.as_str(), + identity.tty, + ), + ("codespace", "g1", "cs-attempt-1", "proc-1", "gov", false) + ); + assert_eq!(identity.resources, ResourceRequest::default()); + assert_eq!(identity.digest, meaning(&ws, &req).digest().unwrap()); + // The authority was asked for this attempt, under this digest and request. + let asked = fake.seen.lock().unwrap()[0].clone(); + assert_eq!(asked.attempt_id, "cs-attempt-1"); + assert_eq!(asked.digest, identity.digest); + assert_eq!(asked.resources, dg_request(&ResourceRequest::default())); + // Requested, required, supported, reserved and applied stay apart. + let account = *prepared.account(); + assert_eq!(account.requested, requested(&ResourceRequest::default())); + assert_eq!(account.required.memory, EnforcementLevel::Accounted); + assert_eq!( + account.supported.unwrap().cpu.method, + ControlMethod::QosAndPriority + ); + assert_eq!(account.reserved.unwrap().quantities.tasks, 32); + assert_eq!(account.applied, Applied::NotLaunched); + // It holds a slot, which is not a process. + assert_eq!(runner.occupied_slots(), 1); + assert!(runner.host_workspace_of("proc-1").is_none()); + assert!(prepared.deadline() > Instant::now()); + let report = prepared.launch(&req).await; + assert_eq!(report.outcome, PreparationOutcome::LaunchUnavailable); + assert_eq!(report.attempt, AttemptState::Cancelled); + assert_eq!(report.account, Some(account)); + assert_eq!((fake.count(ADMIT), fake.count(CANCEL)), (1, 1)); + assert_nothing_started(dir.path(), &runner); + let body = report.into_error_body("gov"); + assert_eq!(body.code, ErrorCode::ManagedLaunchUnavailable); + assert!( + body.message.ends_with( + "attempt `cs-attempt-1` for process_id `proc-1`: cancelled; nothing was started" + ), + "{}", + body.message + ); + } + + #[tokio::test] + async fn denials_and_an_unreachable_authority_reserve_nothing_and_cancel_nothing() { + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + let runner = runner(); + let unavailable = devguard::Registration { + status: devguard::Status { + state: devguard::State::Unavailable, + error_code: Some(devguard::ErrorCode::ResourceControlUnavailable), + report: None, + }, + state: devguard::RegistrationState::Unavailable, + error_code: Some(devguard::ErrorCode::ResourceControlUnavailable), + pid: None, + }; + for (answer, outcome, attempt, code) in [ + ( + attempt( + dg::Phase::Denied, + Some(devguard::ErrorCode::ResourceUnavailable), + ), + PreparationOutcome::Denied { + denial: ResourceAuthorityErrorCode::ResourceUnavailable, + }, + AttemptState::Denied, + ErrorCode::ResourceUnavailable, + ), + ( + attempt( + dg::Phase::Denied, + Some(devguard::ErrorCode::ResourcePolicyUnsupported), + ), + PreparationOutcome::Denied { + denial: ResourceAuthorityErrorCode::ResourcePolicyUnsupported, + }, + AttemptState::Denied, + ErrorCode::ResourcePolicyUnsupported, + ), + ( + Answer::NotSent(unavailable.clone()), + PreparationOutcome::AuthorityUnavailable { + registration: ResourceRegistrationState::Unavailable, + error_code: Some(ResourceAuthorityErrorCode::ResourceControlUnavailable), + }, + AttemptState::NotAsked, + ErrorCode::ResourceAuthorityUnavailable, + ), + ( + Answer::Refused(devguard::ErrorCode::ResourceUnavailable), + PreparationOutcome::Refused { + error_code: ResourceAuthorityErrorCode::ResourceUnavailable, + }, + AttemptState::NotRecorded, + ErrorCode::ResourceAuthorityUnavailable, + ), + ] { + let fake = Fake::admitting([answer]); + let report = Preparer::new(fake.clone(), runner.clone()) + .prepare_and_dispose(ws.clone(), touch(dir.path(), "proc-d"), "cs-d".into()) + .await; + assert_eq!((report.outcome, report.attempt), (outcome, attempt)); + assert_eq!(report.account.unwrap().reserved, None); + assert_eq!( + (fake.count(ADMIT), fake.count(LOOKUP), fake.count(CANCEL)), + (1, 0, 0) + ); + assert_eq!(report.into_error_body("gov").code, code); + assert_nothing_started(dir.path(), &runner); + } + } + + #[tokio::test] + async fn an_occupied_slot_refuses_before_the_authority_is_asked() { + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + let runner = runner().with_max_processes(1); + let held = runner.reserve_slot("proc-held").unwrap(); + let fake = Fake::admitting([]); + let report = Preparer::new(fake.clone(), runner.clone()) + .prepare_and_dispose(ws, touch(dir.path(), "proc-2"), "cs-2".into()) + .await; + assert_eq!( + (report.outcome, report.attempt), + (PreparationOutcome::SlotUnavailable, AttemptState::NotAsked) + ); + assert_eq!(fake.count(ADMIT), 0); + assert_eq!(report.into_error_body("gov").code, ErrorCode::WorkspaceBusy); + drop(held); + assert_nothing_started(dir.path(), &runner); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_requests_over_the_limit_are_refused_before_admission() { + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + let runner = runner().with_max_processes(3); + // Admissions are slow, so all requests hold or seek slots together. + let fake = Fake::admitting([]).slow(Duration::from_millis(200)); + let preparer = Preparer::new(fake.clone(), runner.clone()); + let requests: Vec<_> = (0..8) + .map(|n| { + let (preparer, ws) = (preparer.clone(), ws.clone()); + let req = touch(dir.path(), &format!("proc-c{n}")); + tokio::spawn(async move { + preparer + .prepare_and_dispose(ws, req, format!("cs-c{n}")) + .await + }) + }) + .collect(); + let mut outcomes = Vec::new(); + for request in requests { + outcomes.push(request.await.unwrap().outcome); + } + let admitted = outcomes + .iter() + .filter(|outcome| **outcome == PreparationOutcome::LaunchUnavailable) + .count(); + let refused = outcomes + .iter() + .filter(|outcome| **outcome == PreparationOutcome::SlotUnavailable) + .count(); + assert_eq!((admitted, refused), (3, 5), "{outcomes:?}"); + assert_eq!(fake.count(ADMIT), 3); + assert_nothing_started(dir.path(), &runner); + } + + #[tokio::test] + async fn an_expired_preparation_is_not_launched_and_is_cancelled() { + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + let runner = runner(); + let fake = Fake::admitting([prepared()]).then_cancel(attempt(dg::Phase::Expired, None)); + let req = touch(dir.path(), "proc-e"); + let prepared = Preparer::new(fake.clone(), runner.clone()) + .with_ttl_bound(Duration::from_millis(20)) + .prepare(&ws, req.clone(), "cs-e".into()) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(40)).await; + let report = prepared.launch(&req).await; + assert_eq!( + (report.outcome, report.attempt), + (PreparationOutcome::Expired, AttemptState::Expired) + ); + assert_eq!(fake.count(CANCEL), 1); + assert_eq!( + report.into_error_body("gov").code, + ErrorCode::ResourceUnavailable + ); + assert_nothing_started(dir.path(), &runner); + } + + #[tokio::test] + async fn a_preparation_cancelled_before_launch_frees_its_slot() { + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + let runner = runner(); + let fake = Fake::admitting([prepared()]); + let prepared = Preparer::new(fake.clone(), runner.clone()) + .prepare(&ws, touch(dir.path(), "proc-x"), "cs-x".into()) + .await + .unwrap(); + assert_eq!(runner.occupied_slots(), 1); + assert_eq!(prepared.cancel().await, AttemptState::Cancelled); + assert_eq!(fake.count(CANCEL), 1); + assert_nothing_started(dir.path(), &runner); + } + + #[tokio::test] + async fn another_command_is_not_launched_under_the_prepared_attempt() { + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + let runner = runner(); + let fake = Fake::admitting([prepared()]); + let req = touch(dir.path(), "proc-m"); + let first = Preparer::new(fake.clone(), runner.clone()) + .prepare(&ws, req.clone(), "cs-m".into()) + .await + .unwrap(); + let mut other = req.clone(); + other.argv.push("again".into()); + let report = first.launch(&other).await; + assert_eq!( + (report.outcome, report.attempt), + (PreparationOutcome::DigestMismatch, AttemptState::Cancelled) + ); + // Neither another process ID nor another PTY choice passes for it either. + for change in [ + |req: &mut RunnerExecRequest| req.process_id = ProcessId("proc-other".into()), + |req: &mut RunnerExecRequest| req.tty = true, + ] { + let fake = Fake::admitting([prepared()]); + let each = Preparer::new(fake.clone(), runner.clone()) + .prepare(&ws, req.clone(), "cs-m2".into()) + .await + .unwrap(); + let mut other = req.clone(); + change(&mut other); + assert_eq!( + each.launch(&other).await.outcome, + PreparationOutcome::DigestMismatch + ); + } + assert_nothing_started(dir.path(), &runner); + } + + #[tokio::test] + async fn an_unknown_admission_is_looked_up_never_replayed_and_settled_conservatively() { + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + let runner = runner(); + for (lookup, cancels, expected) in [ + // A timeout or a lost reply after which the attempt is found prepared: cancelled. + (prepared(), 1, AttemptState::Cancelled), + // Found already settled: nothing to cancel. + (attempt(dg::Phase::Expired, None), 0, AttemptState::Expired), + // Not found is not proof that none exists. + ( + Answer::Refused(devguard::ErrorCode::NotFound), + 0, + AttemptState::Unknown, + ), + // No answer to the lookup either. + (Answer::Unknown, 0, AttemptState::Unknown), + ] { + let fake = Fake::admitting([Answer::Unknown]).then_lookup(lookup); + let report = Preparer::new(fake.clone(), runner.clone()) + .prepare_and_dispose(ws.clone(), touch(dir.path(), "proc-u"), "cs-u".into()) + .await; + assert_eq!( + (report.outcome, report.attempt), + (PreparationOutcome::Unknown, expected) + ); + // Asked once, looked up once: never admitted again, never rebuilt into a + // prepared execution. + assert_eq!( + (fake.count(ADMIT), fake.count(LOOKUP), fake.count(CANCEL)), + (1, 1, cancels) + ); + let seen = fake.seen.lock().unwrap(); + assert!(seen.iter().all(|admission| *admission == seen[0])); + drop(seen); + assert_eq!( + report.into_error_body("gov").code, + ErrorCode::AdmissionUnknown + ); + assert_nothing_started(dir.path(), &runner); + } + } + + #[tokio::test] + async fn the_slot_and_the_attempt_are_released_separately() { + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + let runner = runner(); + // The authority answers the admission, then cannot be reached to cancel it. + let gone = devguard::Registration { + status: devguard::Status { + state: devguard::State::Unavailable, + error_code: Some(devguard::ErrorCode::ResourceControlUnavailable), + report: None, + }, + state: devguard::RegistrationState::Unavailable, + error_code: Some(devguard::ErrorCode::ResourceControlUnavailable), + pid: None, + }; + let fake = Fake::admitting([prepared()]).then_cancel(Answer::NotSent(gone)); + let report = Preparer::new(fake.clone(), runner.clone()) + .prepare_and_dispose(ws, touch(dir.path(), "proc-r"), "cs-r".into()) + .await; + // The slot is free, as no process exists; the attempt is not claimed released. + assert_eq!( + (report.outcome, report.attempt), + (PreparationOutcome::LaunchUnavailable, AttemptState::Unknown) + ); + assert_nothing_started(dir.path(), &runner); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_caller_that_stops_waiting_leaves_the_preparation_to_settle() { + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + let runner = runner(); + let fake = Fake::admitting([prepared()]).slow(Duration::from_millis(200)); + let preparer = Preparer::new(fake.clone(), runner.clone()); + let waiting = + preparer.prepare_and_dispose(ws.clone(), touch(dir.path(), "proc-t"), "cs-t".into()); + // The caller's task ends while the admission is in flight. + assert!(tokio::time::timeout(Duration::from_millis(50), waiting) + .await + .is_err()); + for _ in 0..100 { + if fake.count(CANCEL) == 1 && runner.occupied_slots() == 0 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!((fake.count(ADMIT), fake.count(CANCEL)), (1, 1)); + assert_nothing_started(dir.path(), &runner); + + // A prepared execution dropped unsettled is cancelled in the background. + let fake = Fake::admitting([prepared()]); + let prepared = Preparer::new(fake.clone(), runner.clone()) + .prepare(&ws, touch(dir.path(), "proc-t2"), "cs-t2".into()) + .await + .unwrap(); + drop(prepared); + for _ in 0..100 { + if fake.count(CANCEL) == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(fake.count(CANCEL), 1); + assert_nothing_started(dir.path(), &runner); + } + + #[tokio::test] + async fn only_a_required_workspace_with_a_valid_meaning_is_prepared() { + let dir = tempfile::tempdir().unwrap(); + let runner = runner(); + let fake = Fake::admitting([]); + let preparer = Preparer::new(fake.clone(), runner.clone()); + let mut off = governed(dir.path()); + off.resources.participation = Participation::Off; + let report = preparer + .prepare_and_dispose(off, touch(dir.path(), "proc-o"), "cs-o".into()) + .await; + assert_eq!(report.outcome, PreparationOutcome::Invalid); + let mut zero = touch(dir.path(), "proc-z"); + zero.timeout_ms = 0; + let report = preparer + .prepare_and_dispose(governed(dir.path()), zero, "cs-z".into()) + .await; + assert_eq!(report.outcome, PreparationOutcome::Invalid); + assert_eq!(fake.count(ADMIT), 0); + assert_nothing_started(dir.path(), &runner); + } + + #[test] + fn the_meaning_follows_the_command_workspace_and_request() { + let dir = tempfile::tempdir().unwrap(); + let ws = governed(dir.path()); + let req = touch(dir.path(), "proc-1"); + let digest = meaning(&ws, &req).digest().unwrap(); + // The process ID is identity, not meaning. + assert_eq!( + meaning(&ws, &touch(dir.path(), "proc-2")).digest().unwrap(), + digest + ); + let mut other_ws = ws.clone(); + other_ws.id = WorkspaceId("other".into()); + let mut more = ws.clone(); + more.resources.request = Some(ResourceRequest { + tasks: 65, + ..ResourceRequest::default() + }); + let mut tty = req.clone(); + tty.tty = true; + let mut env = req.clone(); + env.env.overrides.insert("X".into(), "1".into()); + let mut network = req.clone(); + network.policy.network = codespace_policy::NetworkAxis::Enabled; + for (ws, req) in [ + (&other_ws, &req), + (&more, &req), + (&ws, &tty), + (&ws, &env), + (&ws, &network), + ] { + assert_ne!(meaning(ws, req).digest().unwrap(), digest); + } + } +} diff --git a/crates/runner/src/lib.rs b/crates/runner/src/lib.rs index c206fb1..38008fe 100644 --- a/crates/runner/src/lib.rs +++ b/crates/runner/src/lib.rs @@ -99,6 +99,8 @@ fn reject_symlink_ancestors(root: &Path, dest: &Path) -> Result<(), ErrorBody> { Ok(()) } +#[cfg(feature = "devguard")] +pub mod admission; mod api; mod apply; mod descriptors; @@ -128,8 +130,8 @@ pub use files::VERSION_ABSENT; pub use fork_handlers::prepare_fork_spawns; pub use patch_helper::ensure_helper_for_tests; pub use process::{ - InProcessRunner, RetentionPolicy, ShellRelease, DEFAULT_COMPLETED_TTL, DEFAULT_MAX_COMPLETED, - DEFAULT_MAX_PROCESSES, DEFAULT_TIMEOUT, + InProcessRunner, RetentionPolicy, ShellRelease, SlotReservation, DEFAULT_COMPLETED_TTL, + DEFAULT_MAX_COMPLETED, DEFAULT_MAX_PROCESSES, DEFAULT_TIMEOUT, }; /// True when this process probed a working Linux sandbox helper. diff --git a/crates/runner/src/process.rs b/crates/runner/src/process.rs index a11d475..76c815d 100644 --- a/crates/runner/src/process.rs +++ b/crates/runner/src/process.rs @@ -4,7 +4,7 @@ //! `codespace-pty` adapter. When the Linux helper probe succeeds, both wrap //! the same helper argv. UDS dispatch lives in `UdsRunner`. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::ffi::OsString; use std::os::unix::process::ExitStatusExt; use std::path::{Path, PathBuf}; @@ -64,11 +64,45 @@ fn max_processes() -> usize { #[derive(Clone)] pub struct InProcessRunner { inner: Arc>>, + /// Slots taken for processes not yet created. Locked after `inner`, never before it. + reserved: Arc>>, + /// The live process limit, when set here instead of by `CODESPACE_MAX_PROCESSES`. + max_processes: Option, on_release: ShellRelease, retention: RetentionPolicy, pub(crate) watches: crate::watch::WatchSet, } +/// A process slot, taken before the process is created (CSRG-U3). A slot counts against the +/// live process limit from the moment it is taken, so no process is created without one. It +/// is freed when it is dropped unfilled; once a created process fills it, the process's slot +/// lasts until the process ends. A slot is not a process handle, an admission or a workspace +/// lease. +pub struct SlotReservation { + reserved: Arc>>, + process_id: String, +} + +impl SlotReservation { + pub fn process_id(&self) -> &str { + &self.process_id + } + + /// Hand the slot to the created process, under the runner's lock. The reservation is + /// dropped only after the process holds the slot. + fn fill(self, map: &mut HashMap, slot: Slot) { + map.insert(self.process_id.clone(), slot); + } +} + +impl Drop for SlotReservation { + fn drop(&mut self) { + if let Ok(mut reserved) = self.reserved.lock() { + reserved.remove(&self.process_id); + } + } +} + struct Slot { workspace_id: String, io: SessionIo, @@ -217,12 +251,58 @@ impl InProcessRunner { pub fn with_retention(on_release: ShellRelease, retention: RetentionPolicy) -> Self { Self { inner: Arc::new(Mutex::new(HashMap::new())), + reserved: Arc::new(Mutex::new(HashSet::new())), + max_processes: None, on_release, retention, watches: crate::watch::WatchSet::default(), } } + /// This runner's live process limit, instead of `CODESPACE_MAX_PROCESSES`. + pub fn with_max_processes(mut self, limit: usize) -> Self { + self.max_processes = Some(limit.max(1)); + self + } + + fn limit(&self) -> usize { + self.max_processes.unwrap_or_else(max_processes) + } + + /// Take a process slot for `process_id` before creating anything (CSRG-U3). Live + /// processes and slots already taken count against the limit, so an over-limit request is + /// refused here, before a process exists. + pub fn reserve_slot(&self, process_id: &str) -> Result { + let mut map = self.inner.lock().expect("runner"); + self.evict_completed(&mut map); + let mut reserved = self.reserved.lock().expect("slots"); + if reserved.contains(process_id) { + return Err(ErrorBody::new( + ErrorCode::InvalidCommand, + format!("process_id `{process_id}` is already in use"), + )); + } + if live_count(&map) + reserved.len() >= self.limit() { + return Err(ErrorBody::new( + ErrorCode::WorkspaceBusy, + "live process limit reached", + )); + } + reserved.insert(process_id.to_string()); + Ok(SlotReservation { + reserved: self.reserved.clone(), + process_id: process_id.to_string(), + }) + } + + /// Live processes and slots taken for processes not yet created. + pub fn occupied_slots(&self) -> usize { + let mut map = self.inner.lock().expect("runner"); + self.evict_completed(&mut map); + let reserved = self.reserved.lock().expect("slots").len(); + live_count(&map) + reserved + } + fn evict_completed(&self, map: &mut HashMap) { let now = Instant::now(); let ttl = self.retention.ttl; @@ -261,6 +341,8 @@ impl InProcessRunner { "command must be a non-empty argv (no shell)", )); } + // The slot first: no process is created without one. + let slot = self.reserve_slot(&req.process_id.0)?; let cap = req.output_bytes_cap.max(1) as usize; let timeout = Duration::from_millis(req.timeout_ms.max(1)); let cwd = match req.cwd { @@ -276,9 +358,9 @@ impl InProcessRunner { process_id: req.process_id.0.clone(), }; if req.tty { - self.spawn_pty(ws, req, ctx).await + self.spawn_pty(ws, req, ctx, slot).await } else { - self.spawn_pipe(ws, req, ctx) + self.spawn_pipe(ws, req, ctx, slot) } } @@ -287,6 +369,7 @@ impl InProcessRunner { ws: &Workspace, req: RunnerExecRequest, ctx: SpawnCtx, + reservation: SlotReservation, ) -> Result { let SpawnCtx { cwd, @@ -335,14 +418,7 @@ impl InProcessRunner { { let mut map = self.inner.lock().expect("runner"); self.evict_completed(&mut map); - if live_count(&map) >= max_processes() { - let _ = child.lock().expect("child").start_kill(); - return Err(ErrorBody::new( - ErrorCode::WorkspaceBusy, - "live process limit reached", - )); - } - map.insert(process_id.clone(), slot); + reservation.fill(&mut map, slot); } let out_handle = stdout.map(|out| { @@ -413,6 +489,7 @@ impl InProcessRunner { ws: &Workspace, req: RunnerExecRequest, ctx: SpawnCtx, + reservation: SlotReservation, ) -> Result { let SpawnCtx { cwd, @@ -467,14 +544,7 @@ impl InProcessRunner { { let mut map = self.inner.lock().expect("runner"); self.evict_completed(&mut map); - if live_count(&map) >= max_processes() { - session.kill(); - return Err(ErrorBody::new( - ErrorCode::WorkspaceBusy, - "live process limit reached", - )); - } - map.insert(process_id.clone(), slot); + reservation.fill(&mut map, slot); } let out_handle = { @@ -904,7 +974,11 @@ fn sandbox_network(network: NetworkAxis) -> SandboxNetwork { } } -fn spawn_env(cwd: &Path, req: &RunnerExecRequest, sandboxed: bool) -> HashMap { +pub(crate) fn spawn_env( + cwd: &Path, + req: &RunnerExecRequest, + sandboxed: bool, +) -> HashMap { let mut env = HashMap::new(); if req.env.use_runner_defaults { let defaults = if sandboxed { @@ -988,6 +1062,108 @@ mod tests { panic!("process did not exit: {}", process_id.0) } + /// The marker file `n`'s command would create, and that command. + fn marker(root: &std::path::Path, n: usize) -> (std::path::PathBuf, RunnerExecRequest) { + let path = root.join(format!("started-{n}")); + let req = RunnerExecRequest::for_host( + vec![ + "/bin/sh".into(), + "-c".into(), + format!("touch '{}'; sleep 30", path.display()), + ], + ProcessId(format!("proc-limit-{n}")), + Profile::WorkspaceWrite, + ); + (path, req) + } + + /// Whether `path` appears within half a second. + async fn appears(path: &std::path::Path) -> bool { + for _ in 0..50 { + if path.exists() { + return true; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + false + } + + #[tokio::test] + async fn a_request_over_the_limit_creates_no_child() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runner = InProcessRunner::new(Arc::new(|_| {})).with_max_processes(8); + for n in 0..8 { + let (path, req) = marker(dir.path(), n); + runner.spawn_host(&ws, req).await.unwrap(); + assert!(appears(&path).await, "process {n} did not start"); + } + // The ninth is refused before anything is created: it never runs, even briefly. + let (ninth, req) = marker(dir.path(), 8); + let refused = runner.spawn_host(&ws, req).await.unwrap_err(); + assert_eq!( + (refused.code, refused.message.as_str()), + (ErrorCode::WorkspaceBusy, "live process limit reached") + ); + assert!( + !appears(&ninth).await, + "the refused request created a child" + ); + assert!(runner.host_workspace_of("proc-limit-8").is_none()); + assert_eq!(runner.occupied_slots(), 8); + // A slot held for a process not yet created counts too. + runner.kill_host_workspace("demo").unwrap(); + for _ in 0..200 { + if runner.occupied_slots() == 0 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + let small = InProcessRunner::new(Arc::new(|_| {})).with_max_processes(1); + let held = small.reserve_slot("proc-held").unwrap(); + let (blocked, req) = marker(dir.path(), 9); + assert_eq!( + small.spawn_host(&ws, req).await.unwrap_err().code, + ErrorCode::WorkspaceBusy + ); + assert!(!appears(&blocked).await); + // A slot dropped unfilled frees itself. + drop(held); + assert_eq!(small.occupied_slots(), 0); + let (freed, req) = marker(dir.path(), 10); + small.spawn_host(&ws, req).await.unwrap(); + assert!(appears(&freed).await); + small.kill_host_workspace("demo").unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_requests_over_the_limit_create_only_the_children_allowed() { + let dir = tempdir().unwrap(); + let ws = workspace(dir.path()); + let runner = InProcessRunner::new(Arc::new(|_| {})).with_max_processes(3); + let spawns: Vec<_> = (0..12) + .map(|n| { + let (runner, ws) = (runner.clone(), ws.clone()); + let (_, req) = marker(dir.path(), n); + tokio::spawn(async move { runner.spawn_host(&ws, req).await }) + }) + .collect(); + let mut started = 0; + for spawn in spawns { + match spawn.await.unwrap() { + Ok(_) => started += 1, + Err(err) => assert_eq!(err.code, ErrorCode::WorkspaceBusy), + } + } + assert_eq!(started, 3); + tokio::time::sleep(Duration::from_millis(300)).await; + let created = (0..12) + .filter(|n| dir.path().join(format!("started-{n}")).exists()) + .count(); + assert_eq!(created, 3, "a refused request created a child"); + runner.kill_host_workspace("demo").unwrap(); + } + fn workspace(root: &std::path::Path) -> Workspace { Workspace::new( WorkspaceId("demo".into()), diff --git a/crates/runner/src/registration.rs b/crates/runner/src/registration.rs index c563938..3f7cc27 100644 --- a/crates/runner/src/registration.rs +++ b/crates/runner/src/registration.rs @@ -47,6 +47,11 @@ impl OwnerRegistration { self.owner.instance_id() } + /// The owner, which admits, looks up and cancels its attempts (CSRG-U3). + pub fn owner(&self) -> Arc { + self.owner.clone() + } + /// The report of a registration session that started after this call arrived. pub async fn report(&self) -> ResourceAuthorityInfo { let arrived = Instant::now(); @@ -139,7 +144,7 @@ fn authority_info( } } -fn registration_state(state: devguard::RegistrationState) -> ResourceRegistrationState { +pub(crate) fn registration_state(state: devguard::RegistrationState) -> ResourceRegistrationState { match state { devguard::RegistrationState::Registered => ResourceRegistrationState::Registered, devguard::RegistrationState::Unavailable => ResourceRegistrationState::Unavailable, @@ -160,7 +165,7 @@ fn registration_state(state: devguard::RegistrationState) -> ResourceRegistratio } } -fn error_code(code: devguard::ErrorCode) -> ResourceAuthorityErrorCode { +pub(crate) fn error_code(code: devguard::ErrorCode) -> ResourceAuthorityErrorCode { match code { devguard::ErrorCode::Unauthorized => ResourceAuthorityErrorCode::Unauthorized, devguard::ErrorCode::InvalidRequest => ResourceAuthorityErrorCode::InvalidRequest, diff --git a/crates/runner/src/uds.rs b/crates/runner/src/uds.rs index 993cef5..9a49759 100644 --- a/crates/runner/src/uds.rs +++ b/crates/runner/src/uds.rs @@ -45,6 +45,9 @@ struct Shared { /// The worker's `Hello` stated that it answers `Registration` (CSRG-U2). #[cfg(feature = "devguard")] registration: AtomicBool, + /// The worker's `Hello` stated that it answers `Prepare` (CSRG-U3). + #[cfg(feature = "devguard")] + admission: AtomicBool, } pub struct UdsRunner { @@ -99,6 +102,8 @@ impl UdsRunner { on_disconnect, #[cfg(feature = "devguard")] registration: AtomicBool::new(false), + #[cfg(feature = "devguard")] + admission: AtomicBool::new(false), }); let reader_shared = shared.clone(); tokio::spawn(async move { @@ -114,10 +119,16 @@ impl UdsRunner { match self.call(RunnerOp::Hello).await { Ok(hello @ RunnerOpResult::Hello { protocol, .. }) if protocol == WIRE_PROTOCOL => { #[cfg(feature = "devguard")] - if let RunnerOpResult::Hello { registration, .. } = hello { + if let RunnerOpResult::Hello { + registration, + admission, + .. + } = hello + { self.shared .registration .store(registration, Ordering::SeqCst); + self.shared.admission.store(admission, Ordering::SeqCst); } #[cfg(not(feature = "devguard"))] let _ = hello; @@ -169,6 +180,64 @@ impl UdsRunner { } } + /// Whether the worker's `Hello` stated that it answers `Prepare` (CSRG-U3). + #[cfg(feature = "devguard")] + pub fn states_admission(&self) -> bool { + self.shared.admission.load(Ordering::SeqCst) + } + + /// Have the worker prepare a governed execution under its own registration (CSRG-U3). The + /// worker reports how the preparation ended; it never starts the command. A worker that did + /// not state `admission` is never asked. When no report comes back the worker may have + /// asked for admission, so the attempt is unknown; when the request never left, nothing + /// was asked. + #[cfg(feature = "devguard")] + pub async fn prepare( + &self, + ws: &Workspace, + request: RunnerExecRequest, + attempt_id: String, + ) -> Result { + use crate::admission::{AttemptState, ManagedPreparation, PreparationOutcome}; + use codespace_domain::ResourceRegistrationState; + if !self.states_admission() { + return Err(RunnerError::execution(ErrorBody::new( + ErrorCode::ResourcePolicyUnsupported, + "this worker cannot prepare governed executions", + ))); + } + let unanswered = |outcome, attempt| ManagedPreparation { + attempt_id: attempt_id.clone(), + process_id: request.process_id.clone(), + outcome, + attempt, + account: None, + }; + match self + .call(RunnerOp::Prepare { + workspace: ws.clone(), + request: request.clone(), + attempt_id: attempt_id.clone(), + }) + .await + { + Ok(RunnerOpResult::Prepared(report)) => Ok(report), + Ok(other) => Err(unexpected(other)), + Err(RunnerError::TransportBeforeDispatch { .. }) => Ok(unanswered( + PreparationOutcome::AuthorityUnavailable { + registration: ResourceRegistrationState::OwnerUnreachable, + error_code: None, + }, + AttemptState::NotAsked, + )), + Err(RunnerError::TransportAmbiguous { .. }) => Ok(unanswered( + PreparationOutcome::Unknown, + AttemptState::Unknown, + )), + Err(err) => Err(err), + } + } + pub async fn close(&self) { self.shared.alive.store(false, Ordering::SeqCst); { diff --git a/crates/runner/src/wire.rs b/crates/runner/src/wire.rs index f609ea9..904887c 100644 --- a/crates/runner/src/wire.rs +++ b/crates/runner/src/wire.rs @@ -170,6 +170,16 @@ pub enum RunnerOp { /// `Hello` states `registration`; a worker without it would close the connection. #[cfg(feature = "devguard")] Registration, + /// Prepare a governed execution under the worker's own registration (CSRG-U3): its slot, + /// its admission and, as managed launch is not available yet, the cancellation of the + /// unstarted attempt. Sent only to a worker whose `Hello` states `admission`. The worker + /// never starts the command. + #[cfg(feature = "devguard")] + Prepare { + workspace: Workspace, + request: RunnerExecRequest, + attempt_id: String, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -194,9 +204,15 @@ pub enum RunnerOpResult { #[cfg(feature = "devguard")] #[serde(default, skip_serializing_if = "is_false")] registration: bool, + /// The worker answers `Prepare` (CSRG-U3). Left out when false, as `registration`. + #[cfg(feature = "devguard")] + #[serde(default, skip_serializing_if = "is_false")] + admission: bool, }, #[cfg(feature = "devguard")] Registration(codespace_domain::ResourceAuthorityInfo), + #[cfg(feature = "devguard")] + Prepared(crate::admission::ManagedPreparation), } #[cfg(feature = "devguard")] @@ -332,6 +348,33 @@ where }); continue; } + #[cfg(feature = "devguard")] + if let Some(RunnerOp::Prepare { + workspace, + request: exec, + attempt_id, + }) = request.op + { + // Beside the other requests, and never cached: a preparation is not replayed. + let (write, registrar, runner) = (write.clone(), registrar.clone(), runner.clone()); + tokio::spawn(async move { + let result = match registrar { + Some(registration) => Ok(RunnerOpResult::Prepared( + crate::admission::Preparer::new(registration.owner(), runner) + .prepare_and_dispose(workspace, exec, attempt_id) + .await, + )), + None => Err(ErrorBody::new( + ErrorCode::ResourcePolicyUnsupported, + "this worker was started without DevGuard settings", + )), + }; + let response = WireEnvelope::response(request_id, result); + let mut writer = write.lock().await; + let _ = write_frame(&mut *writer, &response).await; + }); + continue; + } let response = match request.op { Some(RunnerOp::Replay { request_id: original, @@ -474,10 +517,14 @@ async fn dispatch(runner: &InProcessRunner, op: RunnerOp) -> Result unreachable!("replay is handled before dispatch"), #[cfg(feature = "devguard")] RunnerOp::Registration => unreachable!("registration is handled before dispatch"), + #[cfg(feature = "devguard")] + RunnerOp::Prepare { .. } => unreachable!("preparation is handled before dispatch"), }; result.map_err(RunnerError::into_error_body) } @@ -717,9 +764,43 @@ mod tests { Some(RunnerOpResult::Hello { protocol, registration, - }) => assert_eq!((protocol, registration), (WIRE_PROTOCOL, true)), + admission, + }) => assert_eq!( + (protocol, registration, admission), + (WIRE_PROTOCOL, true, true) + ), other => panic!("unexpected {other:?}"), } + // Nor can it prepare a governed execution; it starts nothing for one. + let dir = tempfile::tempdir().unwrap(); + let mut workspace = Workspace::new( + codespace_domain::WorkspaceId("gov".into()), + dir.path().to_path_buf(), + codespace_domain::Profile::WorkspaceWrite, + ); + workspace.resources.participation = codespace_policy::Participation::Required; + let touch = dir.path().join("started").display().to_string(); + request( + &mut write, + "rrpc-prepare", + RunnerOp::Prepare { + workspace, + request: RunnerExecRequest::for_host( + vec!["/usr/bin/touch".into(), touch], + ProcessId("proc-gov".into()), + codespace_domain::Profile::WorkspaceWrite, + ), + attempt_id: "cs-attempt-1".into(), + }, + ) + .await; + let reply = response(&mut read).await; + assert_eq!(reply.request_id.as_deref(), Some("rrpc-prepare")); + assert_eq!( + reply.error.unwrap().code, + ErrorCode::ResourcePolicyUnsupported + ); + assert!(!dir.path().join("started").exists()); request(&mut write, "rrpc-reg", RunnerOp::Registration).await; let reply = response(&mut read).await; assert_eq!(reply.request_id.as_deref(), Some("rrpc-reg")); @@ -741,6 +822,7 @@ mod tests { old, RunnerOpResult::Hello { registration: false, + admission: false, .. } )); diff --git a/crates/server/src/devguard.rs b/crates/server/src/devguard.rs index 92f7fc1..427ef32 100644 --- a/crates/server/src/devguard.rs +++ b/crates/server/src/devguard.rs @@ -26,6 +26,7 @@ use codespace_domain::{ ResourceAuthorityInfo, ResourceOwner, ResourceParticipation, ResourceRegistrationInfo, ResourceRegistrationState, }; +use codespace_runner::admission::{ManagedPreparation, Preparer}; use codespace_runner::registration::{ handoff, status_info, Owner, OwnerCredential, OwnerRegistration, OwnerSettings, }; @@ -230,6 +231,53 @@ impl Registration { self.owner.kind() } + /// Whether this gateway's execution owner can prepare governed executions with `runner` + /// (CSRG-U3): the gateway itself in InProcess mode, or the worker it started when that + /// worker states it. + pub(crate) fn prepares(&self, runner: &RuntimeBackend) -> bool { + match (&self.owner, runner) { + (ExecutionOwner::InProcess(_), RuntimeBackend::InProcess(_)) => true, + (ExecutionOwner::Worker, RuntimeBackend::Uds(worker)) => worker.states_admission(), + _ => false, + } + } + + /// Have the execution owner prepare `request` as attempt `attempt_id` and report how it + /// ended (CSRG-U3). Nothing is started. Only for an owner that [`Self::prepares`]. + pub(crate) async fn prepare( + &self, + runner: &RuntimeBackend, + ws: &codespace_policy::Workspace, + request: codespace_runner::RunnerExecRequest, + attempt_id: String, + ) -> Result { + let report = match (&self.owner, runner) { + (ExecutionOwner::InProcess(registration), RuntimeBackend::InProcess(runner)) => { + Preparer::new(registration.owner(), runner.clone()) + .prepare_and_dispose(ws.clone(), request, attempt_id) + .await + } + (ExecutionOwner::Worker, RuntimeBackend::Uds(worker)) => worker + .prepare(ws, request, attempt_id) + .await + .map_err(codespace_runner::RunnerError::into_error_body)?, + _ => { + return Err(codespace_domain::ErrorBody::new( + codespace_domain::ErrorCode::ResourcePolicyUnsupported, + "this runner mode cannot prepare governed executions", + )) + } + }; + tracing::info!( + attempt_id = %report.attempt_id, + process_id = %report.process_id.0, + outcome = ?report.outcome, + attempt = ?report.attempt, + "DevGuard preparation; nothing was started" + ); + Ok(report) + } + /// The report of a registration session the owner started after this call arrived, or why /// the owner could not be asked beside the authority's status from the gateway's own probe. pub async fn report(&self, runner: &RuntimeBackend) -> ResourceAuthorityInfo { @@ -1142,6 +1190,24 @@ pub(crate) mod tests { instances.sort(); instances } + + /// The attempts that still hold resources, and the tasks they hold. + pub(crate) fn charged(&mut self) -> (Vec, u64) { + use std::io::BufRead; + let stdin = self.stdin.as_mut().unwrap(); + stdin.write_all(b"charged\n").unwrap(); + stdin.flush().unwrap(); + let mut line = String::new(); + self.stdout.read_line(&mut line).unwrap(); + let value: serde_json::Value = serde_json::from_str(&line).unwrap(); + let attempts = value["attempts"] + .as_array() + .unwrap() + .iter() + .map(|attempt| attempt["attempt_id"].as_str().unwrap().to_owned()) + .collect(); + (attempts, value["committed"]["tasks"].as_u64().unwrap()) + } } impl Drop for FixtureAuthority { @@ -1597,6 +1663,7 @@ pub(crate) mod tests { let hello = RunnerOpResult::Hello { protocol: WIRE_PROTOCOL, registration, + admission: registration, }; let reply = WireEnvelope::response(request.request_id.unwrap(), Ok(hello)); write_frame(&mut server, &reply).await.unwrap(); diff --git a/crates/server/src/mcp.rs b/crates/server/src/mcp.rs index 2d6991c..eb3cd88 100644 --- a/crates/server/src/mcp.rs +++ b/crates/server/src/mcp.rs @@ -296,7 +296,8 @@ impl CodeSpace { if params.command.is_empty() || params.command[0].is_empty() { return Err(err_json(invalid_argv())); } - self.require_participation_off(ws).await.map_err(err_json)?; + // A workspace this gateway cannot govern is refused before any approval or lease. + self.participation(ws).await.map_err(err_json)?; if let Err(err) = self.maybe_hold_exec(ws, ¶ms) { return Err(err_json(err)); } @@ -864,8 +865,8 @@ impl CodeSpace { if params.command.is_empty() || params.command[0].is_empty() { return Err(invalid_argv()); } - // Also here, so an approval resumed after the registry changed starts nothing either. - self.require_participation_off(ws).await?; + // Also here, so an approval resumed after the registry changed is governed as it is now. + let participation = self.participation(ws).await?; let process_id = ProcessId(format!("proc-{}", Uuid::new_v4())); let mut reservation = self .store @@ -874,10 +875,21 @@ impl CodeSpace { if let Some(id) = resume { self.store.mark_resuming(id)?; } - reservation.arm_dispatch(); let mut req = RunnerExecRequest::for_host(params.command, process_id.clone(), ws.profile); req.policy.network = ws.network; req.tty = params.tty; + match participation { + Participating::Off => {} + #[cfg(feature = "devguard")] + Participating::Managed => { + // The workspace lease is held while the owner prepares; nothing is started, so + // it is released with the answer whatever the attempt's own state. + let refused = self.prepare_governed(ws, req).await; + reservation.abort(); + return Err(refused); + } + } + reservation.arm_dispatch(); match self.runner.exec(ws, req).await { Ok(result) => { reservation.confirm(); @@ -903,25 +915,59 @@ impl CodeSpace { } } +/// How a new execution in a workspace takes part in the resource authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Participating { + /// Run as always, without the authority. + Off, + /// Prepare it through the execution owner's admission (CSRG-U3). It never runs outside it. + #[cfg(feature = "devguard")] + Managed, +} + impl CodeSpace { - /// A workspace that requires resource participation starts nothing until CodeSpace admits - /// and launches through the resource authority (CSRG-U3 and U4). Every build refuses its - /// new executions alike, and nothing falls back to running them without the authority. - async fn require_participation_off(&self, ws: &Workspace) -> Result<(), ErrorBody> { + /// How a new execution in `ws` takes part, or why this gateway refuses it. A workspace that + /// requires resource participation is prepared through the execution owner's admission + /// when the gateway was started with `--devguard register` and its owner can prepare; + /// otherwise it is refused. Nothing ever falls back to running it without the authority. + async fn participation(&self, ws: &Workspace) -> Result { if ws.resources.participation == Participation::Off { - return Ok(()); + return Ok(Participating::Off); + } + #[cfg(feature = "devguard")] + if let Some(registration) = &self.registration { + if registration.prepares(&self.runner) { + return Ok(Participating::Managed); + } } Err(ErrorBody::new( ErrorCode::ResourcePolicyUnsupported, format!( - "workspace `{}` requires resource participation: {}; managed resource \ - admission and launch are not implemented yet, so nothing was started", + "workspace `{}` requires resource participation: {}; this gateway cannot \ + admit executions through the resource authority, so nothing was started", ws.id.0, self.participation_readiness().await ), )) } + /// Have the execution owner prepare `req` (CSRG-U3). Managed launch is not available yet, + /// so every preparation ends in a refusal and nothing is started. + #[cfg(feature = "devguard")] + async fn prepare_governed(&self, ws: &Workspace, req: RunnerExecRequest) -> ErrorBody { + let Some(registration) = &self.registration else { + return ErrorBody::new(ErrorCode::Internal, "no execution owner registration"); + }; + let attempt_id = format!("cs-attempt-{}", Uuid::new_v4().simple()); + match registration + .prepare(&self.runner, ws, req, attempt_id) + .await + { + Ok(report) => report.into_error_body(&ws.id.0), + Err(err) => err, + } + } + #[cfg(not(feature = "devguard"))] async fn participation_readiness(&self) -> String { "this CodeSpace was built without DevGuard".into() @@ -1931,8 +1977,10 @@ mod tests { "{refused}" ); assert!( - body.message - .ends_with("managed resource admission and launch are not implemented yet, so nothing was started"), + body.message.ends_with( + "this gateway cannot admit executions through the resource authority, so \ + nothing was started" + ), "{refused}" ); assert!(body.approval_id.is_none() && body.operation_id.is_none()); @@ -2245,7 +2293,7 @@ mod devguard_tests { } #[tokio::test] - async fn a_required_workspace_asks_the_owner_and_still_starts_nothing() { + async fn a_required_workspace_is_prepared_by_the_owner_and_starts_nothing() { let dir = short_directory(); std::fs::write(dir.path().join("a.txt"), "kept").unwrap(); let socket = dir.path().join("authority.sock"); @@ -2259,14 +2307,18 @@ mod devguard_tests { store.clone(), RuntimeBackend::in_process(release(&store)), ); - assert_required_starts_nothing( + assert_governed_starts_nothing( &handler, &store, dir.path(), - "the execution owner is not registered with DevGuard (registration: unavailable)", + ErrorCode::ResourceAuthorityUnavailable, + &[ + "the execution owner could not ask the resource authority (registration: unavailable", + ": not_asked; nothing was started", + ], ) .await; - // Each refusal came from a session the owner opened for it. + // Each preparation came from a session the owner opened for it. assert_eq!(endpoint.accepted(), 3); // Existing process control never asks DevGuard. let started = handler @@ -2317,6 +2369,134 @@ mod devguard_tests { panic!("{process_id:?} did not exit after termination"); } + /// Every new execution in a `gov` workspace goes to the execution owner's preparation and + /// ends in a refusal with `code` whose message contains each of `says`: a pipe and a PTY command, + /// and one held for approval, granted and resumed. Nothing starts, the workspace lease is + /// released each time and the other tools keep working. Resuming the same approval again + /// returns the stored refusal, so its admission is not asked again. Returns the attempt IDs. + pub(crate) async fn assert_governed_starts_nothing( + cs: &CodeSpace, + store: &Store, + root: &std::path::Path, + code: ErrorCode, + says: &[&str], + ) -> Vec { + // DevGuard reads the consumer secret within 250 ms (see `past_the_credential`): such a + // session opens nothing, and the call is made again. + let stopped = |body: &ErrorBody| { + code != ErrorCode::ResourceAuthorityUnavailable + && body + .message + .contains("(registration: credential_unavailable") + }; + let touch = format!("{}/started", root.display()); + let mut attempts = Vec::new(); + let mut check = |body: &ErrorBody, raw: &str| { + assert_eq!(body.code, code, "{raw}"); + for said in says { + assert!(body.message.contains(said), "{raw}"); + } + assert!(body.message.ends_with("; nothing was started"), "{raw}"); + let attempt = body + .message + .split("attempt `") + .nth(1) + .and_then(|rest| rest.split('`').next()) + .unwrap_or_else(|| panic!("no attempt in {raw}")) + .to_owned(); + assert!(attempt.starts_with("cs-attempt-"), "{raw}"); + assert!(!attempts.contains(&attempt), "attempt reused: {raw}"); + attempts.push(attempt); + }; + for tty in [false, true] { + let mut tries = 0; + let (body, raw) = loop { + let raw = cs + .exec_command(Parameters(exec_params( + "gov", + &["/usr/bin/touch", &touch], + tty, + ))) + .await + .err() + .expect("refused"); + let body: ErrorBody = serde_json::from_str(&raw).unwrap(); + tries += 1; + if tries == 5 || !stopped(&body) { + break (body, raw); + } + }; + check(&body, &raw); + drop( + store + .try_acquire_write("gov") + .expect("the lease is released"), + ); + } + let mut tries = 0; + let (body, raw, approval_id) = loop { + let held = cs + .exec_command(Parameters(exec_params( + "gov-confirm", + &["/usr/bin/touch", &touch], + false, + ))) + .await + .err() + .expect("held"); + let held: ErrorBody = serde_json::from_str(&held).unwrap(); + assert_eq!(held.code, ErrorCode::ApprovalRequired); + let approval_id = ApprovalId(held.approval_id.unwrap()); + cs.approval_resolve(Parameters(codespace_domain::ApprovalResolveParams { + approval_id: approval_id.clone(), + decision: codespace_domain::ApprovalDecision::Grant, + })) + .await + .unwrap(); + let raw = cs + .operation_resume(Parameters(OperationResumeParams { + approval_id: approval_id.clone(), + })) + .await + .err() + .expect("refused"); + let body: ErrorBody = serde_json::from_str(&raw).unwrap(); + tries += 1; + if tries == 5 || !stopped(&body) { + break (body, raw, approval_id); + } + }; + check(&body, &raw); + let replayed = cs + .operation_resume(Parameters(OperationResumeParams { approval_id })) + .await + .err() + .expect("refused"); + let replayed: ErrorBody = serde_json::from_str(&replayed).unwrap(); + assert_eq!( + (replayed.code, replayed.message.as_str()), + (body.code, body.message.as_str()) + ); + drop( + store + .try_acquire_write("gov-confirm") + .expect("the lease is released"), + ); + assert!(!root.join("started").exists(), "a governed command ran"); + let read = cs + .read(Parameters(ReadParams { + workspace_id: WorkspaceId("gov".into()), + path: "a.txt".into(), + offset: None, + limit: None, + work_id: None, + })) + .await + .unwrap(); + assert_eq!(read.0.content, "kept"); + attempts + } + /// The gateway registers itself when it runs the executions (InProcess), against /// DevGuard's fixture authority in its own process. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -2358,21 +2538,223 @@ mod devguard_tests { for registration in together { assert_eq!(registration, expected); } - let readiness = if fixture.native { + if fixture.native { // One instance across every session, and it is this process. let instances = fixture.instances(); assert_eq!(instances.len(), 1, "{instances:?}"); assert_eq!(instances[0].1, me); - "the execution owner is registered with DevGuard" - } else { - "the execution owner is not registered with DevGuard (registration: incompatible)" - }; - assert_required_starts_nothing(&handler, &store, dir.path(), readiness).await; + } + assert_governed(&mut fixture, &handler, &store, dir.path()).await; if fixture.native { assert_eq!(fixture.instances().len(), 1); } } + /// With native evidence DevGuard admits each governed execution and CodeSpace cancels it, + /// as managed launch is not available; without it the owner cannot register. + async fn assert_governed( + fixture: &mut FixtureAuthority, + handler: &CodeSpace, + store: &Store, + root: &std::path::Path, + ) { + let (code, says): (_, &[&str]) = if fixture.native { + ( + ErrorCode::ManagedLaunchUnavailable, + &[ + "the resource authority admitted it, but managed launch is not available yet", + ": cancelled; nothing was started", + ], + ) + } else { + ( + ErrorCode::ResourceAuthorityUnavailable, + &[ + "(registration: incompatible, resource_policy_unsupported)", + ": not_asked; nothing was started", + ], + ) + }; + assert_governed_starts_nothing(handler, store, root, code, says).await; + // Nothing the authority admitted is still charged. + assert_eq!(fixture.charged(), (Vec::new(), 0)); + } + + /// The operator's request decides what DevGuard is asked for: more memory than it can + /// give is a shortage, kernel control it cannot give is unsupported. Neither reserves or + /// starts anything. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_shortage_or_an_unsupported_minimum_is_refused_and_starts_nothing() { + let Some(mut fixture) = FixtureAuthority::start(2) else { + return; + }; + if !fixture.native { + return; + } + let dir = tempfile::tempdir().unwrap(); + let touch = format!("{}/started", dir.path().display()); + let request = |memory: u64, minimum: &str| { + serde_json::json!({ + "root": dir.path(), + "profile": "workspace-write", + "resources": {"participation": "required", "request": { + "cpu_milli": 100, "memory_bytes": memory, "tasks": 4, + "minimum": {"cpu": "accounted", "memory": minimum, "pids": "accounted"}, + }}, + }) + }; + let config = serde_json::json!({"workspaces": { + "short": request(1 << 60, "accounted"), + "kernel": request(1 << 20, "kernel"), + }}); + let cli = register_cli(&fixture.settings, &["--runner", "in-process"]); + let store = Arc::new(Store::memory().unwrap()); + let handler = CodeSpace::from_cli( + &cli, + Registry::load_json(&config.to_string()).unwrap(), + store.clone(), + RuntimeBackend::in_process(release(&store)), + ); + for (workspace, code, denial) in [ + ( + "short", + ErrorCode::ResourceUnavailable, + "resource_unavailable", + ), + ( + "kernel", + ErrorCode::ResourcePolicyUnsupported, + "resource_policy_unsupported", + ), + ] { + let body = past_the_credential( + |body: &ErrorBody| body.message.contains("credential_unavailable"), + || async { + let raw = handler + .exec_command(Parameters(exec_params( + workspace, + &["/usr/bin/touch", &touch], + false, + ))) + .await + .err() + .expect("refused"); + serde_json::from_str::(&raw).unwrap() + }, + ) + .await; + assert_eq!(body.code, code, "{}", body.message); + assert!( + body.message + .contains(&format!("the resource authority denied it ({denial})")), + "{}", + body.message + ); + assert!(body.message.ends_with(": denied; nothing was started")); + drop( + store + .try_acquire_write(workspace) + .expect("the lease is released"), + ); + } + assert!(!dir.path().join("started").exists()); + assert_eq!(fixture.charged(), (Vec::new(), 0)); + } + + /// A worker that takes a preparation and is lost before answering may have asked DevGuard + /// to admit it: the gateway reports the admission unknown, not refused, starts nothing and + /// releases the workspace. + #[tokio::test] + async fn a_preparation_lost_with_the_worker_is_unknown() { + use codespace_runner::{ + read_frame, write_frame, RunnerOp, RunnerOpResult, UdsRunner, WireEnvelope, + WIRE_PROTOCOL, + }; + let dir = short_directory(); + std::fs::write(dir.path().join("a.txt"), "kept").unwrap(); + let (client, mut worker) = tokio::net::UnixStream::pair().unwrap(); + let prepared = Arc::new(AtomicBool::new(false)); + let seen = prepared.clone(); + tokio::spawn(async move { + while let Ok(Some(frame)) = read_frame(&mut worker).await { + let request: WireEnvelope = serde_json::from_slice(&frame).unwrap(); + match request.op { + Some(RunnerOp::Hello) => { + let hello = RunnerOpResult::Hello { + protocol: WIRE_PROTOCOL, + registration: true, + admission: true, + }; + let reply = WireEnvelope::response(request.request_id.unwrap(), Ok(hello)); + write_frame(&mut worker, &reply).await.unwrap(); + } + // Lost with the worker before it answers. + Some(RunnerOp::Prepare { .. }) => { + seen.store(true, Ordering::SeqCst); + return; + } + _ => return, + } + } + }); + let store = Arc::new(Store::memory().unwrap()); + let runner = UdsRunner::from_stream(client, release(&store)); + runner.handshake().await.unwrap(); + let handler = CodeSpace::with_store_and_runner( + participation_registry(dir.path(), "required"), + store.clone(), + RuntimeBackend::Uds(runner), + ) + .with_registration(crate::devguard::Registration::new( + settings(dir.path(), dir.path().join("absent.sock")), + crate::config::RunnerMode::Uds, + true, + )); + let touch = format!("{}/started", dir.path().display()); + let raw = handler + .exec_command(Parameters(exec_params( + "gov", + &["/usr/bin/touch", &touch], + false, + ))) + .await + .err() + .expect("refused"); + let body: ErrorBody = serde_json::from_str(&raw).unwrap(); + assert!(prepared.load(Ordering::SeqCst)); + assert_eq!(body.code, ErrorCode::AdmissionUnknown, "{raw}"); + assert!( + body.message.ends_with(": unknown; nothing was started"), + "{raw}" + ); + drop( + store + .try_acquire_write("gov") + .expect("the lease is released"), + ); + assert!(!dir.path().join("started").exists()); + // The connection is gone, so the next request was never sent: nothing was asked. + let raw = handler + .exec_command(Parameters(exec_params( + "gov", + &["/usr/bin/touch", &touch], + false, + ))) + .await + .err() + .expect("refused"); + let body: ErrorBody = serde_json::from_str(&raw).unwrap(); + assert_eq!(body.code, ErrorCode::ResourceAuthorityUnavailable, "{raw}"); + assert!( + body.message.contains("(registration: owner_unreachable)"), + "{raw}" + ); + assert!( + body.message.ends_with(": not_asked; nothing was started"), + "{raw}" + ); + } + /// The UDS worker registers itself and the gateway does not, against DevGuard's fixture /// authority in its own process. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -2416,15 +2798,12 @@ mod devguard_tests { for registration in reported_together(&handler, 8).await { assert_eq!(registration, expected); } - let readiness = if fixture.native { + if fixture.native { // One instance, the worker's; the gateway is not registered. let instances = fixture.instances(); assert_eq!(instances.len(), 1, "{instances:?}"); assert_eq!(instances[0].1, worker_pid); - "the execution owner is registered with DevGuard" - } else { - "the execution owner is not registered with DevGuard (registration: incompatible)" - }; + } // The worker runs, observes and terminates processes without asking DevGuard. let started = handler .exec_command(Parameters(exec_params( @@ -2436,7 +2815,7 @@ mod devguard_tests { .unwrap() .0; assert_process_control_needs_no_registration(&handler, started.process_id).await; - assert_required_starts_nothing(&handler, &store, dir.path(), readiness).await; + assert_governed(&mut fixture, &handler, &store, dir.path()).await; if fixture.native { assert_eq!(fixture.instances().len(), 1); } diff --git a/docs/devguard-integration.md b/docs/devguard-integration.md index 7c2e2da..a2b614f 100644 --- a/docs/devguard-integration.md +++ b/docs/devguard-integration.md @@ -2,13 +2,13 @@ # DevGuard integration roadmap -> **Status: suspended as an implementation directive.** The CS-RG work order, the planned consumer boundary and the design revision 1 references on this page must not be implemented as described; current status and prerequisites are unaffected. This is pending the CS-RG integration-boundary revalidation, an owner-directed review of the CodeSpace integration plan; it is not a work unit. No replacement architecture has been approved; the owner decides after reviewing its results. This notice suspends directives only and relaxes no safety requirement. The text below is retained unchanged for historical traceability, except the current status, the [status-only connection](#devguard-status-connection) of CSRG-U1, which the owner approved on 2026-10-02 outside this suspension, and the [execution-owner registration](#devguard-owner-registration) of CSRG-U2, which the owner directed on 2026-10-03. +> **Status: suspended as an implementation directive.** The CS-RG work order, the planned consumer boundary and the design revision 1 references on this page must not be implemented as described; current status and prerequisites are unaffected. This is pending the CS-RG integration-boundary revalidation, an owner-directed review of the CodeSpace integration plan; it is not a work unit. No replacement architecture has been approved; the owner decides after reviewing its results. This notice suspends directives only and relaxes no safety requirement. The text below is retained unchanged for historical traceability, except the current status, the [status-only connection](#devguard-status-connection) of CSRG-U1, which the owner approved on 2026-10-02 outside this suspension, the [execution-owner registration](#devguard-owner-registration) of CSRG-U2, which the owner directed on 2026-10-03, and the [admission and pre-spawn preparation](#devguard-admission) of CSRG-U3, which the owner directed on 2026-10-07. [English](devguard-integration.md) | [한국어](ko/devguard-integration.md) [DevGuard](https://github.com/novelKR/DevGuard) is an independent resource authority for development workloads, licensed under Apache-2.0 like CodeSpace. Its approved integration path adds a shared admission and accounting layer while CodeSpace keeps process ownership, PTY, input/output, permissions, approval holds and workspace coordination. -**Current status:** DevGuard has completed DG-1 in its independent repository ([`395315d`](https://github.com/novelKR/DevGuard/tree/395315d34b5d458ea1774446727f0cb14bd8a120), [milestone ledger](https://github.com/novelKR/DevGuard/blob/395315d34b5d458ea1774446727f0cb14bd8a120/milestones.json)). On top of the DG-0 contracts it provides native macOS host evidence, an authenticated daemon installed as the current user's LaunchAgent, the fenced launch helper and reconciliation, the `devguard` command-line owner with Cargo adapters, parent leases for candidate tests, and upgrade and repair. Its macOS SLO is qualified for release `0.1.0-5daee5d-b3fa569e` (a release ID, not a Git tag) on the measured host and policy; Linux enforcement is not qualified. CodeSpace consumption (CS-RG) is in progress and not qualified. CSRG-U1 added an opt-in, status-only connection: a gateway built with the `devguard` feature and started with `--devguard status` reports DevGuard's status in `workspace_info`, through DevGuard's client pinned at [`f1f9084`](upstream-lock.md#devguard-client-pin). With CSRG-U2, the process that owns CodeSpace's executions registers itself with DevGuard (`--devguard register`), and a workspace's `resources` setting can require resource participation, which refuses every new execution there until managed admission and launch exist. Nothing is admitted or launched, so a running DevGuard service does not govern CodeSpace execution. The qualified release remains a candidate for execution, not a selected pin; the client pin is a source pin of the client crates. +**Current status:** DevGuard has completed DG-1 in its independent repository ([`395315d`](https://github.com/novelKR/DevGuard/tree/395315d34b5d458ea1774446727f0cb14bd8a120), [milestone ledger](https://github.com/novelKR/DevGuard/blob/395315d34b5d458ea1774446727f0cb14bd8a120/milestones.json)). On top of the DG-0 contracts it provides native macOS host evidence, an authenticated daemon installed as the current user's LaunchAgent, the fenced launch helper and reconciliation, the `devguard` command-line owner with Cargo adapters, parent leases for candidate tests, and upgrade and repair. Its macOS SLO is qualified for release `0.1.0-5daee5d-b3fa569e` (a release ID, not a Git tag) on the measured host and policy; Linux enforcement is not qualified. CodeSpace consumption (CS-RG) is in progress and not qualified. CSRG-U1 added an opt-in, status-only connection: a gateway built with the `devguard` feature and started with `--devguard status` reports DevGuard's status in `workspace_info`, through DevGuard's client pinned at [`f1f9084`](upstream-lock.md#devguard-client-pin). With CSRG-U2, the process that owns CodeSpace's executions registers itself with DevGuard (`--devguard register`), and a workspace's `resources` setting can require resource participation. With CSRG-U3, that owner takes a process slot and has DevGuard admit each such execution before anything is created; as managed launch does not exist yet (CSRG-U4), it then cancels the unstarted attempt and refuses the execution. Nothing is launched through DevGuard and nothing runs without it, so a running DevGuard service does not yet govern a running CodeSpace process. The qualified release remains a candidate for execution, not a selected pin; the client pin is a source pin of the client crates. DevGuard [design revision 1](https://github.com/novelKR/DevGuard/blob/d4981b4c241cff42687f5c2c681b583c7847776e/docs/design-revision-1.md), merged in [DevGuard PR #8](https://github.com/novelKR/DevGuard/pull/8) as `d4981b4c241cff42687f5c2c681b583c7847776e`, revised the CS-RG execution layer and work order. It changes plans and design decisions that bind later implementation. It does not change current CodeSpace behavior or any implementation or qualification status. @@ -85,7 +85,7 @@ CSRG-U2 lets the process that owns CodeSpace's executions establish and prove a DevGuard takes the registered process from the session's peer, so a process can only register itself, and the gateway never registers on its worker's behalf. The owner also checks that the identity DevGuard registered is its own process and stays the same across sessions. -**The session.** Each registration is one bounded session that the owner opens and closes: connect, `Hello` requiring protocol 1 and the capability `static_control_reservations`, `Authenticate`, which must grant `control_service`, `Status`, which must report registration ready, then `Register` with the owner's instance identity. DevGuard's 250 ms frame deadlines bound it at about 2.25 s. The instance identity, `codespace-` and 32 random hexadecimal digits, is minted once per owner process and registered again by every session, so DevGuard keeps one instance per owner: active while a session lasts, suspect after it. A restarted owner is a new process with a new identity. The owner registers at startup, then for each `workspace_info` call and each refused `required` execution; a call that arrives while a session runs waits and shares the next one. +**The session.** Each registration is one bounded session that the owner opens and closes: connect, `Hello` requiring protocol 1 and the capability `static_control_reservations`, `Authenticate`, which must grant `control_service`, `Status`, which must report registration ready, then `Register` with the owner's instance identity. DevGuard's 250 ms frame deadlines bound it at about 2.25 s. The instance identity, `codespace-` and 32 random hexadecimal digits, is minted once per owner process and registered again by every session, so DevGuard keeps one instance per owner: active while a session lasts, suspect after it. A restarted owner is a new process with a new identity. The owner registers at startup and for each `workspace_info` call; a call that arrives while a session runs waits and shares the next one. Each admission, lookup or cancellation of CSRG-U3 is its own session that registers first. **What it reports.** With `register`, `workspace_info.resource_authority` comes from the owner's session: `participation: registration`, `governs_execution: false`, the authority's `state`, `error_code` and `report` as with status, and `registration`: @@ -105,7 +105,7 @@ When the owner cannot be asked (`owner_unreachable`, `unsupported_mode`), the au "resources": {"participation": "required"}}}} ``` -`off`, the default, and a missing setting leave execution as before. With `required`, every new execution, from `exec_command` or a resumed approval, is refused with `RESOURCE_POLICY_UNSUPPORTED` before an approval, a lease or a process exists, because CodeSpace does not yet admit or launch through DevGuard (CSRG-U3 and U4). The message says whether the execution owner is registered. Every build refuses alike, also without the feature or without `--devguard register`, and `required` never falls back to running without the authority. An unknown `resources` setting fails the registry load. Reads, finds, patches and the control of running processes are unaffected. +`off`, the default, and a missing setting leave execution as before. With `required` and `--devguard register`, every new execution goes to the owner's [preparation](#devguard-admission) (CSRG-U3). Without the feature, without `--devguard register`, or with an owner that cannot prepare (a worker the gateway did not start), every new execution, from `exec_command` or a resumed approval, is refused with `RESOURCE_POLICY_UNSUPPORTED` before an approval, a lease or a process exists; the message says whether the execution owner is registered. `required` never falls back to running without the authority. An unknown `resources` setting fails the registry load. Reads, finds, patches and the control of running processes are unaffected. **Process control.** `process_status`, `read_process`, `write_stdin`, `process_resize`, `terminate_process` and timeouts never ask DevGuard. In UDS mode the worker answers registration requests beside the others, so a registration session never delays them. @@ -115,7 +115,57 @@ When the owner cannot be asked (`owner_unreachable`, `unsupported_mode`), the au **Platforms.** On macOS, a DevGuard service with native host evidence registers the owner. Without native evidence, as on Linux before DG-LINUX, DevGuard states no capability, so registration is `incompatible`. -**What it does not do.** No admission, launch, permit, carrier or launch helper. Spawn, PTY, output, timeout, termination, reaping and lifecycle stay with CodeSpace, and DevGuard governs no CodeSpace execution. +**What it does not do.** No launch, permit, carrier or launch helper. Spawn, PTY, output, timeout, termination, reaping and lifecycle stay with CodeSpace. + + + +## Admission and pre-spawn preparation (CSRG-U3) + +CSRG-U3 makes `required` operational up to launch: the execution owner admits each governed execution through DevGuard and holds a one-shot preparation of it. It still starts nothing. Managed launch (`BeginLaunch`, its permit and carrier, the launch helper) is CSRG-U4. + +**Order.** A new execution in a `required` workspace, with `--devguard register`, goes through: + +1. **Authorization and workspace readiness** in the gateway: policy, the approval hold of `approvals: confirm`, then the workspace's mutation lease. +2. **A process slot** in the execution owner's runner, taken before anything is created. A request over the live process limit (`CODESPACE_MAX_PROCESSES`, default 8) is refused with `WORKSPACE_BUSY` here, and DevGuard is not asked. This applies to every execution, governed or not: no process is created without a slot. +3. **Admission** in one bounded session of the registered owner (`Register`, then `Admit`). +4. **A one-shot prepared execution**, which owns the command, the slot and the attempt. + +The owner is the gateway in `in-process` mode and the worker the gateway started in `uds` mode, which receives the request as one `prepare` request and answers how it ended. + +**Attempt identity.** Each execution gets a new attempt ID, `cs-attempt-` and 32 hexadecimal digits, minted for it and never a transport request ID. DevGuard keys it by the owner's consumer and generation. The attempt is bound to its CodeSpace `process_id`, its workspace, whether it has a PTY, its resource request and the digest of its meaning: DevGuard's semantic encoding of the command as given, the workspace with its root, profile and network policy, the exact environment its child would get, its PTY, its timeout and its resource request. DevGuard refuses the same attempt ID with another digest, and an answer for another attempt, owner or digest is never taken for it. + +**Request.** A workspace states what each of its executions asks for: + +```json +{"resources": {"participation": "required", + "request": {"cpu_milli": 250, "memory_bytes": 268435456, "tasks": 32, + "minimum": {"cpu": "accounted", "memory": "accounted", "pids": "accounted"}}}} +``` + +Without `request`, these are the values. Each quantity must be positive; `minimum` is the weakest control accepted per resource: `accounted`, `cooperative` or `kernel`. Admission sessions require DevGuard's `durable_admission` and `static_control_reservations` capabilities. + +**The preparation.** It keeps four stages apart: what was *requested* and the *required* minimum, what DevGuard *supports* for it (scope and, per resource, level and method), what it *reserved* and for how long it holds it unlaunched, and what was *applied*, which stays `not_launched`. It cannot be cloned; launching or cancelling consumes it, so it is used once. Its deadline is DevGuard's prepared lifetime (5 s at the pin), measured from before the admission was asked. Presenting another command, `process_id` or PTY choice for launch is refused and cancels the attempt; so does presenting it after its deadline. A preparation dropped without being settled is cancelled in the background. + +**Until managed launch exists.** After a successful preparation the owner cancels the unstarted attempt and the execution is refused with `MANAGED_LAUNCH_UNAVAILABLE`. Nothing ever runs through another path instead. Every outcome is a refusal that starts nothing, releases the workspace lease and the slot, and states the attempt ID, the `process_id` and what is known of the attempt (`not_asked`, `not_recorded`, `denied`, `cancelled`, `expired`, `other` or `unknown`): + +| Outcome | Tool error | +| --- | --- | +| Admitted, then cancelled | `MANAGED_LAUNCH_UNAVAILABLE` | +| No process slot | `WORKSPACE_BUSY` | +| Denied for a shortage, or the admission expired before launch | `RESOURCE_UNAVAILABLE` | +| Denied because DevGuard cannot give the required control | `RESOURCE_POLICY_UNSUPPORTED` | +| The owner could not ask (registration did not succeed), or DevGuard refused the request | `RESOURCE_AUTHORITY_UNAVAILABLE` | +| No answer came back | `ADMISSION_UNKNOWN` | + +A resumed approval is prepared like a new execution, and resuming it again returns the stored refusal without asking DevGuard again. + +**Uncertainty.** A timeout, a lost reply, an EOF, an answer for something else, or DevGuard's client's transport code is not taken as a refusal, nor as proof that no attempt exists. The owner then looks the attempt up once: found prepared, it is cancelled; found settled, that is reported; otherwise the attempt is `unknown`, and it may hold its reservation until DevGuard expires it. An uncertain attempt is never admitted again under any ID and is never rebuilt into a preparation. In `uds` mode, a `prepare` request the worker may have received without answering is `ADMISSION_UNKNOWN`; one that never left is `RESOURCE_AUTHORITY_UNAVAILABLE` (`owner_unreachable`). Cancelling is the only settlement used, and only where its precondition holds: the owner never asked DevGuard to launch the attempt. + +**Lifetimes.** The workspace lease lasts while the gateway waits for the preparation. The slot lasts while the preparation holds it. The attempt is DevGuard's and ends when DevGuard records it denied, cancelled or expired; CodeSpace frees its slot and lease when it starts nothing, whatever it knows of the attempt, and never reports a release it did not observe. Retained output belongs to processes, and a preparation creates none. A preparation runs as its own task, so a client that stops waiting leaves it to settle. + +**Platforms.** On macOS, with a DevGuard service that has native host evidence, executions are admitted and cancelled. Without native evidence, as on Linux before DG-LINUX, the owner cannot register, so every governed execution is `RESOURCE_AUTHORITY_UNAVAILABLE`. + +**What it does not do.** No `BeginLaunch`, permit, carrier, launch helper, observation, reaping or release integration, and no pin change. Spawn, PTY, output, timeout, termination, reaping and lifecycle stay with CodeSpace. ## CS-RG work order diff --git a/docs/ko/devguard-integration.md b/docs/ko/devguard-integration.md index c0ecd97..ffe8cee 100644 --- a/docs/ko/devguard-integration.md +++ b/docs/ko/devguard-integration.md @@ -2,13 +2,13 @@ # DevGuard 결합 로드맵 -> **상태: 구현 지시로서 효력 중지.** 이 페이지의 ‘CS-RG 작업 순서’, ‘소비 경계’, ‘설계 개정 1 참조’ 절은 적힌 대로 구현하지 않습니다. 현재 상태와 선행 조건은 영향이 없습니다. CodeSpace 통합 계획에 대해 소유자가 지시한 검토인 CS-RG 통합 경계 재검증(작업 단위가 아닙니다)이 끝날 때까지 적용됩니다. 대체 구조는 승인되지 않았으며, 소유자가 재검증 결과를 검토한 뒤 결정합니다. 이 표기는 지시의 효력만 중지하며 어떤 안전 요구도 완화하지 않습니다. 아래 내용은 이력 추적을 위해 바꾸지 않고 남깁니다. 다만 현재 상태, 소유자가 2026-10-02에 이 효력 중지와 별도로 승인한 CSRG-U1의 [상태 전용 연결](#devguard-status-connection), 소유자가 2026-10-03에 지시한 CSRG-U2의 [실행 소유자 등록](#devguard-owner-registration)은 예외입니다. +> **상태: 구현 지시로서 효력 중지.** 이 페이지의 ‘CS-RG 작업 순서’, ‘소비 경계’, ‘설계 개정 1 참조’ 절은 적힌 대로 구현하지 않습니다. 현재 상태와 선행 조건은 영향이 없습니다. CodeSpace 통합 계획에 대해 소유자가 지시한 검토인 CS-RG 통합 경계 재검증(작업 단위가 아닙니다)이 끝날 때까지 적용됩니다. 대체 구조는 승인되지 않았으며, 소유자가 재검증 결과를 검토한 뒤 결정합니다. 이 표기는 지시의 효력만 중지하며 어떤 안전 요구도 완화하지 않습니다. 아래 내용은 이력 추적을 위해 바꾸지 않고 남깁니다. 다만 현재 상태, 소유자가 2026-10-02에 이 효력 중지와 별도로 승인한 CSRG-U1의 [상태 전용 연결](#devguard-status-connection), 소유자가 2026-10-03에 지시한 CSRG-U2의 [실행 소유자 등록](#devguard-owner-registration), 소유자가 2026-10-07에 지시한 CSRG-U3의 [실행 허용과 spawn 전 준비](#devguard-admission)는 예외입니다. [English](../devguard-integration.md) | [한국어](devguard-integration.md) [DevGuard](https://github.com/novelKR/DevGuard)는 개발 작업의 자원을 중앙에서 관리하는 독립 시스템이며 CodeSpace와 동일한 Apache-2.0 라이선스를 적용한다. 승인된 결합 경로에 따라 실행 허용과 자원 회계를 공통 계층에 맡기고, CodeSpace는 프로세스 소유권, PTY, 입출력, 권한, 승인 hold와 workspace 조정을 계속 담당한다. -**현재 상태:** DevGuard는 독립 저장소에서 DG-1을 완료했다([`395315d`](https://github.com/novelKR/DevGuard/tree/395315d34b5d458ea1774446727f0cb14bd8a120), [마일스톤 원장](https://github.com/novelKR/DevGuard/blob/395315d34b5d458ea1774446727f0cb14bd8a120/milestones.json)). DG-0 계약 위에 macOS 실제 호스트 증거, 현재 사용자의 LaunchAgent로 설치하는 인증 daemon, fenced launch helper와 대조, Cargo adapter를 갖춘 `devguard` 명령행 owner, 후보 시험용 parent lease, upgrade와 repair를 제공한다. 측정한 호스트와 정책에서 release `0.1.0-5daee5d-b3fa569e`(Git tag가 아닌 release ID)의 macOS SLO qualification을 마쳤으며 Linux 강제 보호는 qualification하지 않았다. CodeSpace 소비(CS-RG)는 진행 중이며 qualification을 마치지 않았다. CSRG-U1은 opt-in 상태 전용 연결을 추가했다. `devguard` feature로 빌드하고 `--devguard status`로 시작한 gateway는 [`f1f9084`](upstream-lock.md#devguard-client-pin)에 고정한 DevGuard client로 DevGuard의 상태를 `workspace_info`에 보고한다. CSRG-U2부터는 CodeSpace의 실행을 소유한 프로세스가 스스로를 DevGuard에 등록하며(`--devguard register`), workspace의 `resources` 설정으로 자원 참여를 필수로 지정할 수 있다. 필수로 지정한 workspace는 관리형 실행 허용과 실행이 생길 때까지 새 실행을 모두 거부한다. 아무것도 허용하거나 실행하지 않으므로 DevGuard 서비스가 실행 중이어도 CodeSpace 실행을 관리하지 않는다. qualification을 마친 release는 실행에 쓸 후보로 남으며 선택된 pin이 아니다. client pin은 client crate의 소스 pin이다. +**현재 상태:** DevGuard는 독립 저장소에서 DG-1을 완료했다([`395315d`](https://github.com/novelKR/DevGuard/tree/395315d34b5d458ea1774446727f0cb14bd8a120), [마일스톤 원장](https://github.com/novelKR/DevGuard/blob/395315d34b5d458ea1774446727f0cb14bd8a120/milestones.json)). DG-0 계약 위에 macOS 실제 호스트 증거, 현재 사용자의 LaunchAgent로 설치하는 인증 daemon, fenced launch helper와 대조, Cargo adapter를 갖춘 `devguard` 명령행 owner, 후보 시험용 parent lease, upgrade와 repair를 제공한다. 측정한 호스트와 정책에서 release `0.1.0-5daee5d-b3fa569e`(Git tag가 아닌 release ID)의 macOS SLO qualification을 마쳤으며 Linux 강제 보호는 qualification하지 않았다. CodeSpace 소비(CS-RG)는 진행 중이며 qualification을 마치지 않았다. CSRG-U1은 opt-in 상태 전용 연결을 추가했다. `devguard` feature로 빌드하고 `--devguard status`로 시작한 gateway는 [`f1f9084`](upstream-lock.md#devguard-client-pin)에 고정한 DevGuard client로 DevGuard의 상태를 `workspace_info`에 보고한다. CSRG-U2부터는 CodeSpace의 실행을 소유한 프로세스가 스스로를 DevGuard에 등록하며(`--devguard register`), workspace의 `resources` 설정으로 자원 참여를 필수로 지정할 수 있다. CSRG-U3부터 그 소유자는 이런 실행마다 무엇을 만들기 전에 프로세스 slot을 잡고 DevGuard의 실행 허용을 받는다. 관리형 실행(CSRG-U4)이 아직 없으므로 그다음 시작하지 않은 attempt를 취소하고 실행을 거부한다. DevGuard를 통해 실행하는 것도, DevGuard 없이 실행하는 것도 없으므로 DevGuard 서비스가 실행 중이어도 아직 실행 중인 CodeSpace 프로세스를 관리하지 않는다. qualification을 마친 release는 실행에 쓸 후보로 남으며 선택된 pin이 아니다. client pin은 client crate의 소스 pin이다. DevGuard [설계 개정 1](https://github.com/novelKR/DevGuard/blob/d4981b4c241cff42687f5c2c681b583c7847776e/docs/ko/design-revision-1.md)은 [DevGuard PR #8](https://github.com/novelKR/DevGuard/pull/8)의 병합 `d4981b4c241cff42687f5c2c681b583c7847776e`로 반영되었으며 CS-RG의 실행 계층과 작업 순서를 개정했다. 이 개정은 계획과 이후 구현을 구속하는 설계 결정을 바꾼다. 현재 CodeSpace 동작이나 구현·qualification 상태는 바꾸지 않는다. @@ -85,7 +85,7 @@ CSRG-U2는 CodeSpace의 실행을 소유한 프로세스가 DevGuard에 등록 DevGuard는 등록할 프로세스를 세션의 peer에서 얻으므로 프로세스는 자신만 등록할 수 있고, gateway가 worker를 대신해 등록하는 일은 없다. 소유자는 DevGuard가 등록한 정체성이 자기 프로세스이며 세션마다 같은지도 확인한다. -**세션.** 등록 한 번은 소유자가 열고 닫는 상한 있는 세션 하나다. 연결하고, protocol 1과 capability `static_control_reservations`를 요구하는 `Hello`, `control_service`를 부여해야 하는 `Authenticate`, 등록 준비를 보고해야 하는 `Status`를 거쳐 소유자의 instance 정체성으로 `Register`한다. DevGuard의 frame당 250 ms 기한 때문에 세션은 약 2.25초 안에 끝난다. instance 정체성(`codespace-`와 무작위 16진수 32자리)은 소유자 프로세스마다 한 번 만들고 모든 세션이 다시 등록하므로, DevGuard에는 소유자마다 instance가 하나만 있다. 세션 동안에는 active이고 세션이 끝나면 suspect가 된다. 다시 시작한 소유자는 새 프로세스이므로 새 정체성을 쓴다. 소유자는 시작할 때, 그리고 `workspace_info` 호출과 거부되는 `required` 실행마다 등록한다. 세션이 진행 중일 때 들어온 호출은 기다렸다가 다음 세션 결과를 함께 쓴다. +**세션.** 등록 한 번은 소유자가 열고 닫는 상한 있는 세션 하나다. 연결하고, protocol 1과 capability `static_control_reservations`를 요구하는 `Hello`, `control_service`를 부여해야 하는 `Authenticate`, 등록 준비를 보고해야 하는 `Status`를 거쳐 소유자의 instance 정체성으로 `Register`한다. DevGuard의 frame당 250 ms 기한 때문에 세션은 약 2.25초 안에 끝난다. instance 정체성(`codespace-`와 무작위 16진수 32자리)은 소유자 프로세스마다 한 번 만들고 모든 세션이 다시 등록하므로, DevGuard에는 소유자마다 instance가 하나만 있다. 세션 동안에는 active이고 세션이 끝나면 suspect가 된다. 다시 시작한 소유자는 새 프로세스이므로 새 정체성을 쓴다. 소유자는 시작할 때와 `workspace_info` 호출마다 등록한다. 세션이 진행 중일 때 들어온 호출은 기다렸다가 다음 세션 결과를 함께 쓴다. CSRG-U3의 실행 허용, 조회, 취소는 각각 먼저 등록하는 별도 세션이다. **보고하는 내용.** `register`이면 `workspace_info.resource_authority`는 소유자의 세션에서 나온다. `participation: registration`, `governs_execution: false`, 상태 연결과 같은 authority의 `state`, `error_code`, `report`, 그리고 `registration`: @@ -105,7 +105,7 @@ DevGuard는 등록할 프로세스를 세션의 peer에서 얻으므로 프로 "resources": {"participation": "required"}}}} ``` -기본값 `off`와 설정이 없는 경우는 실행을 이전과 같게 둔다. `required`이면 `exec_command`나 재개한 승인으로 들어온 새 실행을 승인, lease, 프로세스가 생기기 전에 `RESOURCE_POLICY_UNSUPPORTED`로 모두 거부한다. CodeSpace는 아직 DevGuard를 통해 실행을 허용하거나 실행하지 않기 때문이다(CSRG-U3과 U4). 메시지는 실행 소유자가 등록되었는지 알려 준다. feature 없이 빌드했거나 `--devguard register` 없이 시작한 경우도 포함해 모든 빌드가 같게 거부하며, `required`가 자원 관리 없이 실행하는 쪽으로 물러서는 일은 없다. 알 수 없는 `resources` 설정은 registry 로드를 실패시킨다. 읽기, 찾기, 패치와 실행 중인 프로세스의 제어는 영향을 받지 않는다. +기본값 `off`와 설정이 없는 경우는 실행을 이전과 같게 둔다. `required`이고 `--devguard register`로 시작했으면 새 실행은 모두 소유자의 [준비](#devguard-admission)로 간다(CSRG-U3). feature 없이 빌드했거나 `--devguard register` 없이 시작했거나 소유자가 준비할 수 없으면(gateway가 시작하지 않은 worker) `exec_command`나 재개한 승인으로 들어온 새 실행을 승인, lease, 프로세스가 생기기 전에 `RESOURCE_POLICY_UNSUPPORTED`로 모두 거부한다. 메시지는 실행 소유자가 등록되었는지 알려 준다. `required`가 자원 관리 없이 실행하는 쪽으로 물러서는 일은 없다. 알 수 없는 `resources` 설정은 registry 로드를 실패시킨다. 읽기, 찾기, 패치와 실행 중인 프로세스의 제어는 영향을 받지 않는다. **프로세스 제어.** `process_status`, `read_process`, `write_stdin`, `process_resize`, `terminate_process`와 timeout은 DevGuard에 묻지 않는다. UDS 모드의 worker는 등록 요청을 다른 요청과 나란히 처리하므로 등록 세션이 이들을 지연시키지 않는다. @@ -115,7 +115,57 @@ DevGuard는 등록할 프로세스를 세션의 peer에서 얻으므로 프로 **플랫폼.** macOS에서는 실제 호스트 증거를 가진 DevGuard 서비스가 소유자를 등록한다. 실제 증거가 없으면(DG-LINUX 전의 Linux) DevGuard가 capability를 하나도 밝히지 않으므로 등록은 `incompatible`이다. -**하지 않는 일.** 실행 허용, 실행, permit, carrier, launch helper는 없다. spawn, PTY, 출력, timeout, 종료, reap, lifecycle은 CodeSpace에 남으며 DevGuard는 CodeSpace 실행을 관리하지 않는다. +**하지 않는 일.** 실행, permit, carrier, launch helper는 없다. spawn, PTY, 출력, timeout, 종료, reap, lifecycle은 CodeSpace에 남는다. + + + +## 실행 허용과 spawn 전 준비(CSRG-U3) + +CSRG-U3은 `required`를 실행 직전까지 동작하게 한다. 실행 소유자는 관리 대상 실행마다 DevGuard의 실행 허용을 받고 그 실행의 일회용 준비를 보유한다. 여전히 아무것도 시작하지 않는다. 관리형 실행(`BeginLaunch`, 그 permit과 carrier, launch helper)은 CSRG-U4다. + +**순서.** `--devguard register`로 시작한 gateway에서 `required` workspace의 새 실행은 다음을 거친다. + +1. gateway의 **권한과 workspace 준비**: 정책, `approvals: confirm`의 승인 hold, 그다음 workspace의 변경 lease. +2. 무엇을 만들기 전에 실행 소유자의 runner에서 잡는 **프로세스 slot**. 실행 중 프로세스 상한(`CODESPACE_MAX_PROCESSES`, 기본 8)을 넘는 요청은 여기서 `WORKSPACE_BUSY`로 거부하며 DevGuard에 묻지 않는다. 관리 대상이든 아니든 모든 실행에 적용된다. slot 없이는 프로세스를 만들지 않는다. +3. 등록된 소유자의 상한 있는 세션 하나(`Register` 후 `Admit`)에서 받는 **실행 허용**. +4. 명령, slot, attempt를 소유하는 **일회용 준비된 실행**. + +소유자는 `in-process` 모드에서는 gateway, `uds` 모드에서는 gateway가 시작한 worker다. worker는 요청을 `prepare` 요청 하나로 받아 어떻게 끝났는지 답한다. + +**attempt 정체성.** 실행마다 새 attempt ID(`cs-attempt-`와 16진수 32자리)를 만들며, 전송 요청 ID를 쓰지 않는다. DevGuard는 이를 소유자의 consumer와 generation으로 구분한다. attempt는 CodeSpace `process_id`, workspace, PTY 여부, 자원 요청, 실행 의미의 digest에 묶인다. digest는 DevGuard의 의미 인코딩으로, 주어진 명령, root·profile·network 정책을 포함한 workspace, 자식이 받을 정확한 환경, PTY, timeout, 자원 요청을 덮는다. DevGuard는 같은 attempt ID에 다른 digest를 거부하며, 다른 attempt, 소유자, digest에 대한 답을 이 attempt의 답으로 받아들이지 않는다. + +**요청.** workspace는 실행마다 무엇을 요청할지 적는다. + +```json +{"resources": {"participation": "required", + "request": {"cpu_milli": 250, "memory_bytes": 268435456, "tasks": 32, + "minimum": {"cpu": "accounted", "memory": "accounted", "pids": "accounted"}}}} +``` + +`request`가 없으면 이 값을 쓴다. 수량은 모두 양수여야 한다. `minimum`은 자원별로 받아들이는 가장 약한 제어로 `accounted`, `cooperative`, `kernel` 중 하나다. 실행 허용 세션은 DevGuard의 `durable_admission`과 `static_control_reservations` capability를 요구한다. + +**준비.** 네 단계를 구분해 보관한다. *요청*한 양과 *필수* 최소 제어, DevGuard가 그에 대해 *지원*하는 것(scope와 자원별 level·method), *예약*한 양과 실행 없이 유지하는 기간, *적용*한 것이며 마지막은 `not_launched`로 남는다. 복제할 수 없고 실행이나 취소가 소비하므로 한 번만 쓰인다. 기한은 DevGuard의 준비 유지 기간(pin 기준 5초)이며 실행 허용을 묻기 전부터 잰다. 실행할 때 다른 명령, `process_id`, PTY 선택을 내밀면 거부하고 attempt를 취소한다. 기한이 지난 뒤에 내밀어도 같다. 정리하지 않고 버린 준비는 백그라운드에서 취소한다. + +**관리형 실행이 생기기 전까지.** 준비에 성공하면 소유자는 시작하지 않은 attempt를 취소하고 실행을 `MANAGED_LAUNCH_UNAVAILABLE`로 거부한다. 대신 다른 경로로 실행하는 일은 없다. 모든 결과는 아무것도 시작하지 않는 거부이며 workspace lease와 slot을 놓고, attempt ID, `process_id`, attempt에 대해 아는 것(`not_asked`, `not_recorded`, `denied`, `cancelled`, `expired`, `other`, `unknown`)을 밝힌다. + +| 결과 | 도구 오류 | +| --- | --- | +| 허용된 뒤 취소 | `MANAGED_LAUNCH_UNAVAILABLE` | +| 프로세스 slot 없음 | `WORKSPACE_BUSY` | +| 자원 부족으로 거부되었거나 실행 전에 허용이 만료됨 | `RESOURCE_UNAVAILABLE` | +| DevGuard가 필수 제어를 줄 수 없어 거부됨 | `RESOURCE_POLICY_UNSUPPORTED` | +| 소유자가 물을 수 없었거나(등록 실패) DevGuard가 요청을 거부함 | `RESOURCE_AUTHORITY_UNAVAILABLE` | +| 답이 오지 않음 | `ADMISSION_UNKNOWN` | + +재개한 승인은 새 실행처럼 준비하며, 같은 승인을 다시 재개하면 DevGuard에 다시 묻지 않고 저장된 거부를 돌려준다. + +**불확실성.** timeout, 잃어버린 응답, EOF, 다른 것에 대한 답, DevGuard client의 전송 오류 코드는 거부로도, attempt가 없다는 증거로도 받아들이지 않는다. 이때 소유자는 attempt를 한 번 조회한다. 준비 상태로 발견하면 취소하고, 이미 정리된 상태면 그대로 보고하며, 그렇지 않으면 attempt는 `unknown`이고 DevGuard가 만료시킬 때까지 예약을 유지할 수 있다. 불확실한 attempt는 어떤 ID로도 다시 허용을 받지 않으며 준비로 재구성하지 않는다. `uds` 모드에서 worker가 받았을 수 있으나 답하지 않은 `prepare` 요청은 `ADMISSION_UNKNOWN`, 떠나지도 못한 요청은 `RESOURCE_AUTHORITY_UNAVAILABLE`(`owner_unreachable`)이다. 쓰는 정리는 취소뿐이며 그 전제가 성립할 때만 쓴다. 소유자는 DevGuard에 그 attempt의 실행을 요청한 적이 없다. + +**수명.** workspace lease는 gateway가 준비를 기다리는 동안, slot은 준비가 보유하는 동안 유지된다. attempt는 DevGuard의 것이며 DevGuard가 거부, 취소, 만료를 기록할 때 끝난다. CodeSpace는 아무것도 시작하지 않을 때 attempt에 대해 무엇을 알든 slot과 lease를 놓으며, 관찰하지 않은 해제를 보고하지 않는다. 보관된 출력은 프로세스의 것이고 준비는 프로세스를 만들지 않는다. 준비는 별도 task로 실행하므로 기다림을 멈춘 client가 있어도 정리를 마친다. + +**플랫폼.** macOS에서 실제 호스트 증거를 가진 DevGuard 서비스와 함께라면 실행을 허용받고 취소한다. 실제 증거가 없으면(DG-LINUX 전의 Linux) 소유자가 등록할 수 없으므로 관리 대상 실행은 모두 `RESOURCE_AUTHORITY_UNAVAILABLE`이다. + +**하지 않는 일.** `BeginLaunch`, permit, carrier, launch helper, 관찰, reap, 해제 통합과 pin 변경은 없다. spawn, PTY, 출력, timeout, 종료, reap, lifecycle은 CodeSpace에 남는다. ## CS-RG 작업 순서 diff --git a/docs/ko/operations.md b/docs/ko/operations.md index a21c8c3..b69c4ba 100644 --- a/docs/ko/operations.md +++ b/docs/ko/operations.md @@ -127,7 +127,7 @@ worker는 같은 호스트에서 실행하는 별도 프로세스이며 컨테 | `RUST_LOG` | stderr 로그 수준. 기본값 `info` | | `CODESPACE_OPERATIONS_DB` 미설정 | 패치 작업과 지시 큐를 메모리에 보관하며 재시작 시 사라짐 | | `CODESPACE_PROCESS_TIMEOUT_SECS` | 양의 정수. 기본 30초. 러너 환경에 설정 | -| `CODESPACE_MAX_PROCESSES` | 러너 전체의 실행 중 프로세스 기본 상한 8개. 작업 공간별 점유 규칙도 적용 | +| `CODESPACE_MAX_PROCESSES` | 러너 전체의 실행 중 프로세스 기본 상한 8개. 넘는 요청은 프로세스를 만들기 전에 거부. 작업 공간별 점유 규칙도 적용 | | 프로세스 출력 | 마지막 256 KiB 보관. stdout/stderr를 합침. `read_process`는 `output_lost`와 `retained_from`을 보고하고, 종료는 `process_status`로 조회. 실행 중인 PTY 크기는 `process_resize`로 변경 | | 종료된 핸들 | 기본 최대 15분, 최대 64개 보관. 영구 저장하지 않음 | diff --git a/docs/operations.md b/docs/operations.md index e740e8c..b4660d0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -119,7 +119,7 @@ The worker runs on the same host and is not a container. The gateway creates a p | `RUST_LOG` | stderr tracing level; default `info` | | `CODESPACE_OPERATIONS_DB` unset | In-memory patch operations and instruction queue; lost on restart | | `CODESPACE_PROCESS_TIMEOUT_SECS` | Positive integer; default 30 seconds; set in the runner environment | -| `CODESPACE_MAX_PROCESSES` | Default 8 live processes across the runner; workspace occupancy still applies | +| `CODESPACE_MAX_PROCESSES` | Default 8 live processes across the runner; a request over it is refused before any process is created; workspace occupancy still applies | | Process output | Last 256 KiB retained; stdout/stderr combined; `read_process` reports `output_lost` and `retained_from`; `process_status` reports termination; `process_resize` resizes a running PTY | | Completed handles | Default retention up to 15 minutes and 64 completed entries; not durable | diff --git a/docs/translations.json b/docs/translations.json index 1aeb41f..c270b76 100644 --- a/docs/translations.json +++ b/docs/translations.json @@ -37,10 +37,12 @@ "source": "docs/devguard-integration.md", "translation": "docs/ko/devguard-integration.md", "anchors": [ + "admission-and-pre-spawn-preparation-csrg-u3", "consumer-boundary", "cs-rg-work-order", "cs-rg-작업-순서", "design-revision-1-references", + "devguard-admission", "devguard-integration", "devguard-integration-roadmap", "devguard-owner-registration", @@ -63,11 +65,12 @@ "설계-개정-1-참조", "소비-경계", "실행-소유자-등록csrg-u2", + "실행-허용과-spawn-전-준비csrg-u3", "최소-도입-조건", "최초-gateway-복구-범위" ], - "source_sha256": "1e5cd2d452a76056fa08c6ca1eaea25e44faa07c668850a36cb920b62ce01f8c", - "translation_sha256": "4dfc6ecbf1004cf739ae9c6894d3296d13a5f20290759770b1d4b55f4bb66ace" + "source_sha256": "5728c2db61d3ec202e501bac1486723520a9531164c3d44d31dbee8803ec3e1a", + "translation_sha256": "1e7e586f22939d2bd6fbc8fe1ac76dc4db8874417b68d4d2b8c224132e085169" }, { "id": "overview", @@ -192,8 +195,8 @@ "작업-공간-등록", "첫-연결-확인" ], - "source_sha256": "d98494a628e5bb6a717f4ccf75836e92235764e25f448dbcc2d75d94695cc690", - "translation_sha256": "387686ca418ee694e316441eb2dc047d85d0d2e304aece6090ff5d5016051a40" + "source_sha256": "004d143763000d45ac44ee5b95cd2f62810d8b89024488bc4aebada37e00b9a5", + "translation_sha256": "cfabfcac93d4f9d8fe68d67c7237fc9c0555543c4ce9459f8d52e71a8a942e1f" }, { "id": "chatgpt-connector", diff --git a/scripts/validate-upstream.py b/scripts/validate-upstream.py index 30b6d71..6052a2c 100644 --- a/scripts/validate-upstream.py +++ b/scripts/validate-upstream.py @@ -65,12 +65,13 @@ def stages(): + [cargo('test', 'root', '-p', 'codespace-runner', '--lib', '--', 'descriptors', 'fork_handlers', 'missing_executable_is_process_spawn_failed'), cargo('test', 'root', '-p', 'codespace-server', '--lib', '--', 'descriptors')] # The DevGuard adapter against an authority with native host evidence, and the DevGuard - # tests of the runner, the worker and the gateway where socket creation is not atomically + # tests of the runner (registration, its wire and the preparation of governed executions, + # CSRG-U3), the worker and the gateway where socket creation is not atomically # close-on-exec, the gateway's against the worker and the fixture authority built here. + devguard_binaries() + [cargo('test', 'devguard'), cargo('test', 'codex-runtime', '--features', 'devguard', '--bin', 'codespace-codex-runtime'), - cargo('test', 'root', '-p', 'codespace-runner', '--features', 'devguard', '--lib', '--', 'registration', 'wire'), + cargo('test', 'root', '-p', 'codespace-runner', '--features', 'devguard', '--lib', '--', 'registration', 'wire', 'admission'), cargo('test', 'root', '-p', 'codespace-server', '--features', 'devguard', '--lib', '--', 'devguard')], # Children killed inside fork on macOS: fresh-process trials of reads concurrent with # pipe spawns (crates/runner/tests/fork_race.rs); any child without an exit code fails.