Bound and configure the per-Domain runsc dcache

This commit is contained in:
ms2k 2026-07-24 23:34:02 +09:00
commit db9a561d0c
13 changed files with 157 additions and 11 deletions

View file

@ -10,8 +10,8 @@ use sha2::{Digest, Sha256};
use crate::identity::{ConfigurationFingerprint, DeviceId, DomainId, HostPath, InodeId, MountId}; use crate::identity::{ConfigurationFingerprint, DeviceId, DomainId, HostPath, InodeId, MountId};
use crate::records::{ use crate::records::{
ConfigurationFingerprintRecordV1, MailboxRequest, ResolvedAutoApprovalRule, ResolvedVolume, ConfigurationFingerprintRecordV1, MAX_RUNSC_DCACHE, MailboxRequest, ResolvedAutoApprovalRule,
TRUSTED_HOST_CONFIGURATION_SCHEMA_ID, TrustedHostConfigurationV1, VolumeKind, ResolvedVolume, TRUSTED_HOST_CONFIGURATION_SCHEMA_ID, TrustedHostConfigurationV1, VolumeKind,
}; };
use confined_io::{io_error, open_absolute_directory, validate_executable}; use confined_io::{io_error, open_absolute_directory, validate_executable};
use validation::validate_sandbox_path; use validation::validate_sandbox_path;
@ -27,6 +27,7 @@ pub enum ConfigurationErrorKind {
InvalidProfile, InvalidProfile,
InvalidPath, InvalidPath,
PathConflict, PathConflict,
InvalidRuntime,
InvalidNetwork, InvalidNetwork,
MissingEnvironment, MissingEnvironment,
InvalidVolume, InvalidVolume,
@ -72,6 +73,7 @@ const fn error_label(kind: ConfigurationErrorKind) -> &'static str {
ConfigurationErrorKind::InvalidProfile => "invalid profile", ConfigurationErrorKind::InvalidProfile => "invalid profile",
ConfigurationErrorKind::InvalidPath => "invalid path", ConfigurationErrorKind::InvalidPath => "invalid path",
ConfigurationErrorKind::PathConflict => "path conflict", ConfigurationErrorKind::PathConflict => "path conflict",
ConfigurationErrorKind::InvalidRuntime => "invalid runtime configuration",
ConfigurationErrorKind::InvalidNetwork => "invalid network policy", ConfigurationErrorKind::InvalidNetwork => "invalid network policy",
ConfigurationErrorKind::MissingEnvironment => "invalid environment reference", ConfigurationErrorKind::MissingEnvironment => "invalid environment reference",
ConfigurationErrorKind::InvalidVolume => "invalid volume", ConfigurationErrorKind::InvalidVolume => "invalid volume",
@ -679,6 +681,11 @@ impl ResolvedDomainConfiguration {
&self.fingerprint_record &self.fingerprint_record
} }
#[must_use]
pub const fn runsc_dcache(&self) -> u32 {
self.fingerprint_record.runsc_dcache
}
#[must_use] #[must_use]
pub fn effective_allow_prefixes(&self) -> &[Ipv4Prefix] { pub fn effective_allow_prefixes(&self) -> &[Ipv4Prefix] {
&self.effective_allow_prefixes &self.effective_allow_prefixes

View file

@ -343,6 +343,7 @@ pub fn load_trusted_configuration(
selected_profile: request.selected_profile.clone(), selected_profile: request.selected_profile.clone(),
storage_root: host_path(&storage_canonical), storage_root: host_path(&storage_canonical),
runtime_root: host_path(&runtime_canonical), runtime_root: host_path(&runtime_canonical),
runsc_dcache: configuration.runtime().runsc_dcache,
image: selected.image.clone(), image: selected.image.clone(),
isolate_gitignored: selected.isolate_gitignored, isolate_gitignored: selected.isolate_gitignored,
persistent_dirs, persistent_dirs,

View file

@ -4,7 +4,7 @@ use super::confined_io::{
}; };
use super::{ use super::{
ConfigurationError, ConfigurationErrorKind, ConfigurationFingerprint, ConfigurationError, ConfigurationErrorKind, ConfigurationFingerprint,
ConfigurationFingerprintRecordV1, Digest, DomainId, Sha256, ConfigurationFingerprintRecordV1, Digest, DomainId, MAX_RUNSC_DCACHE, Sha256,
TRUSTED_HOST_CONFIGURATION_SCHEMA_ID, TrustedHostConfigurationV1, TRUSTED_HOST_CONFIGURATION_SCHEMA_ID, TrustedHostConfigurationV1,
}; };
@ -33,6 +33,7 @@ pub fn derive_configuration_fingerprint(
push_bytes(&mut preimage, record.selected_profile.as_bytes())?; push_bytes(&mut preimage, record.selected_profile.as_bytes())?;
push_bytes(&mut preimage, record.storage_root.as_bytes())?; push_bytes(&mut preimage, record.storage_root.as_bytes())?;
push_bytes(&mut preimage, record.runtime_root.as_bytes())?; push_bytes(&mut preimage, record.runtime_root.as_bytes())?;
preimage.extend_from_slice(&record.runsc_dcache.to_be_bytes());
push_bytes(&mut preimage, record.image.as_bytes())?; push_bytes(&mut preimage, record.image.as_bytes())?;
push_bool(&mut preimage, record.isolate_gitignored); push_bool(&mut preimage, record.isolate_gitignored);
@ -87,6 +88,12 @@ pub(super) fn validate_structural_configuration(
"unknown configuration schema", "unknown configuration schema",
)); ));
} }
if configuration.runtime().runsc_dcache > MAX_RUNSC_DCACHE {
return Err(ConfigurationError::new(
ConfigurationErrorKind::InvalidRuntime,
format!("runsc_dcache exceeds {MAX_RUNSC_DCACHE} entries"),
));
}
for (name, profile) in configuration.profiles() { for (name, profile) in configuration.profiles() {
validate_profile_name(name)?; validate_profile_name(name)?;
validate_text(&profile.image, "profile image")?; validate_text(&profile.image, "profile image")?;

View file

@ -6,6 +6,8 @@ pub const DOMAIN_STORE_SCHEMA_VERSION: u32 = 1;
pub const STORAGE_CATALOG_SCHEMA_VERSION: u32 = 1; pub const STORAGE_CATALOG_SCHEMA_VERSION: u32 = 1;
pub const TRUSTED_HOST_CONFIGURATION_SCHEMA_ID: &str = "lazaret-trusted-host-config-v1"; pub const TRUSTED_HOST_CONFIGURATION_SCHEMA_ID: &str = "lazaret-trusted-host-config-v1";
pub const DEFAULT_PERSISTENT_DIRS: [&str; 5] = ["/root", "/usr", "/var", "/etc", "/opt"]; pub const DEFAULT_PERSISTENT_DIRS: [&str; 5] = ["/root", "/usr", "/var", "/etc", "/opt"];
pub const DEFAULT_RUNSC_DCACHE: u32 = 50_000;
pub const MAX_RUNSC_DCACHE: u32 = 262_144;
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@ -23,6 +25,25 @@ pub struct NetworkConfigurationV1 {
pub default_deny: Vec<String>, pub default_deny: Vec<String>,
} }
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeConfigurationV1 {
#[serde(default = "default_runsc_dcache")]
pub runsc_dcache: u32,
}
impl Default for RuntimeConfigurationV1 {
fn default() -> Self {
Self {
runsc_dcache: DEFAULT_RUNSC_DCACHE,
}
}
}
const fn default_runsc_dcache() -> u32 {
DEFAULT_RUNSC_DCACHE
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum VolumeKind { pub enum VolumeKind {
@ -103,6 +124,7 @@ pub struct TrustedHostConfigurationV1 {
schema_id: String, schema_id: String,
storage: StorageConfigurationV1, storage: StorageConfigurationV1,
network: NetworkConfigurationV1, network: NetworkConfigurationV1,
runtime: RuntimeConfigurationV1,
profiles: BTreeMap<String, ProfileConfigurationV1>, profiles: BTreeMap<String, ProfileConfigurationV1>,
} }
@ -122,6 +144,11 @@ impl TrustedHostConfigurationV1 {
&self.network &self.network
} }
#[must_use]
pub const fn runtime(&self) -> &RuntimeConfigurationV1 {
&self.runtime
}
#[must_use] #[must_use]
pub const fn profiles(&self) -> &BTreeMap<String, ProfileConfigurationV1> { pub const fn profiles(&self) -> &BTreeMap<String, ProfileConfigurationV1> {
&self.profiles &self.profiles
@ -142,6 +169,8 @@ impl<'de> Deserialize<'de> for TrustedHostConfigurationV1 {
storage: StorageConfigurationV1, storage: StorageConfigurationV1,
#[serde(default)] #[serde(default)]
network: NetworkConfigurationV1, network: NetworkConfigurationV1,
#[serde(default)]
runtime: RuntimeConfigurationV1,
profiles: BTreeMap<String, ProfileConfigurationV1>, profiles: BTreeMap<String, ProfileConfigurationV1>,
} }
@ -160,6 +189,7 @@ impl<'de> Deserialize<'de> for TrustedHostConfigurationV1 {
schema_id: fields.schema_id, schema_id: fields.schema_id,
storage: fields.storage, storage: fields.storage,
network: fields.network, network: fields.network,
runtime: fields.runtime,
profiles: fields.profiles, profiles: fields.profiles,
}) })
} }
@ -192,6 +222,7 @@ pub struct ConfigurationFingerprintRecordV1 {
pub selected_profile: String, pub selected_profile: String,
pub storage_root: HostPath, pub storage_root: HostPath,
pub runtime_root: HostPath, pub runtime_root: HostPath,
pub runsc_dcache: u32,
pub image: String, pub image: String,
pub isolate_gitignored: bool, pub isolate_gitignored: bool,
pub persistent_dirs: Vec<String>, pub persistent_dirs: Vec<String>,

View file

@ -13,8 +13,8 @@ use lazaret_core::configuration::{
}; };
use lazaret_core::identity::HostPath; use lazaret_core::identity::HostPath;
use lazaret_core::records::{ use lazaret_core::records::{
ConfigurationFingerprintRecordV1, EnvironmentReference, ResolvedAutoApprovalRule, ConfigurationFingerprintRecordV1, DEFAULT_RUNSC_DCACHE, EnvironmentReference, MAX_RUNSC_DCACHE,
ResolvedVolume, VolumeAccess, VolumeKind, ResolvedAutoApprovalRule, ResolvedVolume, VolumeAccess, VolumeKind,
}; };
use static_assertions::assert_not_impl_any; use static_assertions::assert_not_impl_any;
@ -150,6 +150,7 @@ image = "example.invalid/agent:v1"
assert!(parsed.storage().data_root.is_none()); assert!(parsed.storage().data_root.is_none());
assert!(parsed.network().default_allow.is_empty()); assert!(parsed.network().default_allow.is_empty());
assert!(parsed.network().default_deny.is_empty()); assert!(parsed.network().default_deny.is_empty());
assert_eq!(parsed.runtime().runsc_dcache, DEFAULT_RUNSC_DCACHE);
for (value, expected) in [ for (value, expected) in [
("[]", Vec::<String>::new()), ("[]", Vec::<String>::new()),
@ -212,6 +213,47 @@ volumes = [{ host = "/source", container = "/root/source", kind = "socket" }]
} }
} }
#[test]
fn runtime_dcache_defaults_accepts_budget_bounds_and_rejects_excess() {
let minimal = r#"schema = "lazaret-trusted-host-config-v1"
[profiles.default]
image = "example.invalid/agent:v1"
"#;
assert_eq!(
parse_trusted_configuration(minimal.as_bytes())
.unwrap()
.runtime()
.runsc_dcache,
DEFAULT_RUNSC_DCACHE
);
for value in [0, MAX_RUNSC_DCACHE] {
let explicit = minimal.replace(
"[profiles.default]",
&format!("[runtime]\nrunsc_dcache = {value}\n[profiles.default]"),
);
assert_eq!(
parse_trusted_configuration(explicit.as_bytes())
.unwrap()
.runtime()
.runsc_dcache,
value
);
}
let excessive = minimal.replace(
"[profiles.default]",
&format!(
"[runtime]\nrunsc_dcache = {}\n[profiles.default]",
MAX_RUNSC_DCACHE + 1
),
);
assert_eq!(
parse_trusted_configuration(excessive.as_bytes())
.unwrap_err()
.kind(),
ConfigurationErrorKind::InvalidRuntime
);
}
#[test] #[test]
fn sandbox_layout_is_closed_normalized_and_requires_workspace() { fn sandbox_layout_is_closed_normalized_and_requires_workspace() {
assert!(SandboxLayoutV1::from_protected_destinations(["/proc".into()]).is_err()); assert!(SandboxLayoutV1::from_protected_destinations(["/proc".into()]).is_err());
@ -834,6 +876,7 @@ fn digest_vectors_are_framed_and_order_independent() {
selected_profile: "default".into(), selected_profile: "default".into(),
storage_root: HostPath::new(b"/data".to_vec()), storage_root: HostPath::new(b"/data".to_vec()),
runtime_root: HostPath::new(b"/run/user/1000/lazaret".to_vec()), runtime_root: HostPath::new(b"/run/user/1000/lazaret".to_vec()),
runsc_dcache: DEFAULT_RUNSC_DCACHE,
image: "example.invalid/agent:v1".into(), image: "example.invalid/agent:v1".into(),
isolate_gitignored: true, isolate_gitignored: true,
persistent_dirs: vec!["/var".into(), "/root".into()], persistent_dirs: vec!["/var".into(), "/root".into()],
@ -867,9 +910,9 @@ fn digest_vectors_are_framed_and_order_independent() {
assert_eq!( assert_eq!(
first.as_bytes(), first.as_bytes(),
&[ &[
0xd3, 0xba, 0xef, 0x91, 0x21, 0x0f, 0x98, 0x31, 0x6c, 0xea, 0x34, 0xfb, 0x41, 0xbd, 0x5f, 0x1d, 0xeb, 0x46, 0xa4, 0xb5, 0x90, 0xc3, 0x47, 0x0b, 0xa0, 0xdf, 0xd6, 0x6e,
0x6c, 0xa5, 0x07, 0xf0, 0xb8, 0x9b, 0x90, 0x77, 0xaf, 0x0a, 0x0e, 0xd0, 0x6a, 0xd6, 0x9d, 0x91, 0xdb, 0xf3, 0x75, 0xf6, 0xbe, 0x50, 0x9a, 0x8d, 0xba, 0x4a, 0x3d, 0xae,
0x46, 0xc7, 0x2d, 0xbf, 0xf3, 0x05, 0x97, 0x56,
] ]
); );
record.persistent_dirs.reverse(); record.persistent_dirs.reverse();

View file

@ -195,6 +195,7 @@ struct RawTrustedHostConfigurationV1 {
schema_id: String, schema_id: String,
storage: StorageConfigurationV1, storage: StorageConfigurationV1,
network: NetworkConfigurationV1, network: NetworkConfigurationV1,
runtime: RuntimeConfigurationV1,
profiles: BTreeMap<String, ProfileConfigurationV1>, profiles: BTreeMap<String, ProfileConfigurationV1>,
} }

View file

@ -189,6 +189,7 @@ fn trusted_host_configuration_postcard_dispatch_rejects_unknown_schema() {
schema_id: schema_id.into(), schema_id: schema_id.into(),
storage: StorageConfigurationV1::default(), storage: StorageConfigurationV1::default(),
network: NetworkConfigurationV1::default(), network: NetworkConfigurationV1::default(),
runtime: RuntimeConfigurationV1::default(),
profiles: profiles.clone(), profiles: profiles.clone(),
}; };
assert!(matches!( assert!(matches!(

View file

@ -569,6 +569,7 @@ persistent_dirs = []
selected_profile: "trusted".into(), selected_profile: "trusted".into(),
storage_root: HostPath::new(b"/data/lazaret".to_vec()), storage_root: HostPath::new(b"/data/lazaret".to_vec()),
runtime_root: HostPath::new(b"/run/user/1000/lazaret".to_vec()), runtime_root: HostPath::new(b"/run/user/1000/lazaret".to_vec()),
runsc_dcache: DEFAULT_RUNSC_DCACHE,
image: "example.invalid/agent:v1".into(), image: "example.invalid/agent:v1".into(),
isolate_gitignored: true, isolate_gitignored: true,
persistent_dirs: vec!["/root".into()], persistent_dirs: vec!["/root".into()],
@ -641,8 +642,8 @@ persistent_dirs = []
assert_eq!( assert_eq!(
seen.digest(), seen.digest(),
[ [
245, 169, 157, 164, 67, 211, 165, 23, 129, 62, 119, 92, 247, 248, 131, 4, 252, 42, 34, 23, 80, 80, 177, 148, 141, 234, 213, 240, 108, 167, 148, 252, 121, 212, 128, 5, 195,
214, 207, 60, 126, 219, 81, 68, 159, 6, 26, 88, 233, 108, 176, 126, 71, 76, 141, 196, 174, 125, 212, 31, 115, 155, 178, 139,
] ]
); );
} }

View file

@ -693,7 +693,11 @@ fn build_sandbox_bundle_from_source(
.map_err(oci_error)?; .map_err(oci_error)?;
let specification_json = canonical_specification_json(&specification)?; let specification_json = canonical_specification_json(&specification)?;
let bundle_authorization = Sha256::digest(&specification_json).into(); let bundle_authorization = Sha256::digest(&specification_json).into();
let command = build_runsc_command(&paths, bundle_authorization); let command = build_runsc_command(
&paths,
request.configuration.runsc_dcache(),
bundle_authorization,
);
let runtime_sources = let runtime_sources =
handoff_runtime_sources(&paths, request.terminal.as_ref(), &specification_json); handoff_runtime_sources(&paths, request.terminal.as_ref(), &specification_json);
Ok(SandboxBundle { Ok(SandboxBundle {

View file

@ -278,6 +278,7 @@ pub(super) fn build_linux() -> Result<oci_spec::runtime::Linux, SandboxBuildErro
pub(super) fn build_runsc_command( pub(super) fn build_runsc_command(
paths: &RuntimePaths, paths: &RuntimePaths,
dcache: u32,
bundle_authorization: [u8; 32], bundle_authorization: [u8; 32],
) -> RunscCommand { ) -> RunscCommand {
RunscCommand::new( RunscCommand::new(
@ -292,6 +293,7 @@ pub(super) fn build_runsc_command(
)), )),
OsString::from("-network=host"), OsString::from("-network=host"),
OsString::from("-ignore-cgroups"), OsString::from("-ignore-cgroups"),
OsString::from(format!("-dcache={dcache}")),
OsString::from(format!( OsString::from(format!(
"-overlay2=root:dir={}", "-overlay2=root:dir={}",
paths paths

View file

@ -373,6 +373,7 @@ fn assert_runtime_command(bundle: &SandboxBundle, fixture: &Fixture) {
format!("-root={runtime_domain}/runsc"), format!("-root={runtime_domain}/runsc"),
"-network=host".to_owned(), "-network=host".to_owned(),
"-ignore-cgroups".to_owned(), "-ignore-cgroups".to_owned(),
"-dcache=50000".to_owned(),
format!( format!(
"-overlay2=root:dir={runtime_domain}/runsc-overlay/{}", "-overlay2=root:dir={runtime_domain}/runsc-overlay/{}",
"11".repeat(32) "11".repeat(32)
@ -402,6 +403,39 @@ fn assert_runtime_command(bundle: &SandboxBundle, fixture: &Fixture) {
})); }));
} }
#[test]
fn configured_dcache_is_fingerprinted_and_passed_to_runsc() {
let fixture = Fixture::new();
let configuration_path = fixture.tree.path().join("configuration/lazaret.toml");
let source = fs::read_to_string(&configuration_path).unwrap();
fs::write(
&configuration_path,
source.replace("[network]", "[runtime]\nrunsc_dcache = 12345\n\n[network]"),
)
.unwrap();
let configuration = fixture.reload_configuration();
assert_eq!(configuration.runsc_dcache(), 12_345);
assert_ne!(
configuration.configuration_fingerprint(),
fixture.configuration.configuration_fingerprint()
);
let bundle = build_sandbox_bundle(SandboxBuildRequest {
configuration: &configuration,
container_id: ContainerId::new([0x11; 32]),
expected_network_namespace: current_network_namespace(),
root_process: root_process([]),
terminal: None,
prepared_root: fixture.tree.path().join("prepared-root"),
})
.unwrap();
assert!(
bundle
.command()
.arguments()
.contains(&"-dcache=12345".into())
);
}
#[test] #[test]
fn product_init_role_has_exact_terminal_neutral_rootless_bundle_shape() { fn product_init_role_has_exact_terminal_neutral_rootless_bundle_shape() {
let fixture = Fixture::new(); let fixture = Fixture::new();

View file

@ -8,6 +8,9 @@ data_root = "~/.local/share/lazaret"
[network] [network]
default_allow = ["192.0.2.53/32", "198.51.100.53/32"] default_allow = ["192.0.2.53/32", "198.51.100.53/32"]
[runtime]
runsc_dcache = 50000
[profiles.default] [profiles.default]
image = "docker.io/library/debian:trixie" image = "docker.io/library/debian:trixie"
isolate_gitignored = true isolate_gitignored = true

View file

@ -299,6 +299,17 @@ An omitted profile `persistent_dirs` field selects `/root`, `/usr`, `/var`,
`/etc`, and `/opt`. An explicit list replaces that default in full, including an `/etc`, and `/opt`. An explicit list replaces that default in full, including an
empty list or paths used by an unorthodox distribution. empty list or paths used by an unorthodox distribution.
The optional `runtime.runsc_dcache` field sets the maximum number of
zero-reference dentries retained by the global runsc gofer-client cache in each
Domain sandbox. It defaults to 50,000 and accepts values from 0 through 262,144
inclusive; zero disables zero-reference dentry retention. The maximum keeps the
cache-entry ceiling at half of the 524,288 per-process descriptor budget,
leaving nominal headroom for referenced files and non-cache activity.
Configured capacity is allocated on demand, while populated capacity consumes
Domain-local sentry and routed-workspace descriptors and memory. Concurrent
Domains have independent caches and their host resource consumption is
additive.
The loader resolves environment references only after structural, path, and The loader resolves environment references only after structural, path, and
network validation. A missing, non-UTF-8, or NUL-containing host value refuses network validation. A missing, non-UTF-8, or NUL-containing host value refuses
the selected profile. Resolved values live only in one move-owned, the selected profile. Resolved values live only in one move-owned,