Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `POST /vms/create` (`ProvisionRequest`) and `POST /vms/{id}/fork` (`ForkRequest`)
accept optional advisory `labels` (`<= 64` entries, ASCII keys `1..=64`
matching the VM-name rule `[A-Za-z0-9][A-Za-z0-9_-]{0,63}`, values `<= 255`
UTF-8 bytes without control characters; `{}` is treated as no labels),
returned on `SandboxInfo.labels` across `/vms/list` and `/vms/{id}/info`.
Labels are set at creation, immutable afterward, preserved across `persist`
and `resume`, and inherited on `fork` unless overridden (with `{}` clearing).
`Hypervisor.create` and `VM.fork` across the Python, Rust, and TypeScript
SDKs (as well as `capsem create -l KEY=VALUE` and `capsem fork -l KEY=VALUE`)
attach or override `labels`.
- Images can be named from the catalog. The service reads
`ghcr.io/google/capsem/catalog:stable` (or the mirror `[images] catalog`
names, trusting `[images] catalog_ca` for it; `catalog = false` turns it
Expand Down
8 changes: 4 additions & 4 deletions config/gate.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1109,10 +1109,10 @@ must_stay_below_lines = 3000
"crates/capsem-mock-server/src/main.rs" = 1138
"crates/capsem-process/src/vsock.rs" = 1227
"crates/capsem-proto/src/tests.rs" = 1446
"crates/capsem-service/src/tests/assets_registry.rs" = 1111
"crates/capsem-service/src/tests/lifecycle.rs" = 1443
"crates/capsem-service/src/vm_files.rs" = 1042
"crates/capsem/src/main.rs" = 2015
"crates/capsem-service/src/tests/assets_registry.rs" = 1070
"crates/capsem-service/src/tests/lifecycle.rs" = 1391
"crates/capsem-service/src/vm_files.rs" = 1059
"crates/capsem/src/main.rs" = 2007
"crates/capsem/src/tests.rs" = 1227
"crates/capsem/src/update.rs" = 2143
"crates/capsem/src/update/asset_install.rs" = 1125
Expand Down
1 change: 1 addition & 0 deletions crates/capsem-api/src/containers/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ fn container_spec_debug_never_prints_credentials_args_or_env_values() {
cpus: None,
persistent: false,
env: None,
labels: None,
from: None,
networks: Vec::new(),
container: Some(private_spec()),
Expand Down
10 changes: 10 additions & 0 deletions crates/capsem-api/src/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,19 @@ pub struct ProvisionRequest {
/// OCI image the service pulls, stages and starts as this VM's workload.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub container: Option<crate::ContainerSpec>,
/// Key-value metadata labels attached to the sandbox at creation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub labels: Option<HashMap<String, String>>,
}

#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
pub struct ForkRequest {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Key-value metadata labels for the forked sandbox. If absent, inherits the source's labels.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub labels: Option<HashMap<String, String>>,
}

#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
Expand Down Expand Up @@ -163,6 +169,9 @@ pub struct SandboxInfo {
pub forked_from: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Key-value metadata labels attached when the sandbox was created.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub labels: Option<HashMap<String, String>>,
/// On-disk size of the session dir in bytes. Populated for /info on
/// persistent VMs; useful for verifying that fork produced a compact
/// overlay and not a bloated sparse file.
Expand Down Expand Up @@ -239,6 +248,7 @@ impl SandboxInfo {
version: None,
forked_from: None,
description: None,
labels: None,
size_bytes: None,
storage: None,
session_db: None,
Expand Down
32 changes: 32 additions & 0 deletions crates/capsem-service/src/api/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,14 @@ fn provision_request_env_omitted() {
cpus: Some(2),
persistent: false,
env: None,
labels: None,
from: None,
networks: Vec::new(),
container: None,
};
let json = serde_json::to_string(&r).unwrap();
assert!(!json.contains("env"));
assert!(!json.contains("labels"));
assert!(!json.contains("from"));
}

Expand Down Expand Up @@ -356,3 +358,33 @@ fn network_logs_query_reads_type_and_defaults_the_rest() {
NetworkLogsQuery::default()
);
}

#[test]
fn provision_request_and_sandbox_info_labels_roundtrip() {
let req: ProvisionRequest = serde_json::from_value(json!({
"labels": {"suite": "eval", "sample": "1"}
}))
.unwrap();
assert_eq!(
req.labels.as_ref().and_then(|l| l.get("suite")).map(String::as_str),
Some("eval")
);
let mut info = SandboxInfo::new("vm-1".into(), 1234, VmLifecycleState::Running, false);
assert!(!serde_json::to_value(&info)
.unwrap()
.as_object()
.unwrap()
.contains_key("labels"));
info.labels = req.labels;
let encoded = serde_json::to_value(&info).unwrap();
assert_eq!(encoded["labels"]["suite"], "eval");
let decoded: SandboxInfo = serde_json::from_value(encoded).unwrap();
assert_eq!(
decoded
.labels
.as_ref()
.and_then(|l| l.get("sample"))
.map(String::as_str),
Some("1")
);
}
2 changes: 2 additions & 0 deletions crates/capsem-service/src/instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ pub(crate) struct InstanceInfo {
pub(crate) env: Option<std::collections::HashMap<String, String>>,
/// Sandbox this VM was cloned from, if any
pub(crate) forked_from: Option<String>,
/// Key-value metadata labels attached at creation
pub(crate) labels: Option<std::collections::HashMap<String, String>>,
/// What the VM owner shows when it asks the service about private names
/// on the VM's behalf: minted at spawn, written to the session directory
/// for the owner alone, matched here. Never reaches the guest.
Expand Down
1 change: 1 addition & 0 deletions crates/capsem-service/src/instance/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ fn nil_selected_generation_refuses_provision_before_any_session_side_effect() {
version_override: None,
persistent: false,
env: None,
labels: None,
from: None,
description: None,
},
Expand Down
3 changes: 2 additions & 1 deletion crates/capsem-service/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ impl Drop for ServicePidfile {

use capsem_service::api;
use capsem_service::api::*;
use capsem_service::naming::{generate_session_name, validate_vm_name};
use capsem_service::naming::{generate_session_name, non_empty_labels, validate_vm_labels, validate_vm_name};
use capsem_service::registry::{
new_persistent_vm_id, BootAssetPin, BootAssetPins, PersistentRegistry, PersistentVmEntry, SharedRegistry,
};
Expand Down Expand Up @@ -335,6 +335,7 @@ pub struct ProvisionOptions<'a> {
pub version_override: Option<String>,
pub persistent: bool,
pub env: Option<std::collections::HashMap<String, String>>,
pub labels: Option<std::collections::HashMap<String, String>>,
pub from: Option<CloneFrom>,
pub description: Option<String>,
}
Expand Down
1 change: 1 addition & 0 deletions crates/capsem-service/src/managed_sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ impl ManagedLifecycle {
version_override: None,
persistent: false,
env: capsem_core::container::session_env(request.env, request.container.is_some()),
labels: crate::non_empty_labels(request.labels),
from: request.from.map(|source| CloneFrom {
source,
replace_image: request.container.is_some(),
Expand Down
35 changes: 34 additions & 1 deletion crates/capsem-service/src/naming.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ where
format!("{base}-{}", rand::thread_rng().gen_range(10_000..99_999))
}

/// Validate that a persistent VM name is safe for use as a directory name.
/// Validate that a persistent VM name (or VM label key) is safe for use as an identifier.
///
/// Rules:
/// - non-empty
Expand All @@ -48,5 +48,38 @@ pub fn validate_vm_name(name: &str) -> Result<()> {
Ok(())
}

/// Validate user-supplied advisory VM labels before persisting or registering a VM.
///
/// Rules:
/// - at most 64 entries
/// - keys: reuse the VM-name rule (`validate_vm_name`: `1..=64` ASCII chars starting with
/// `[A-Za-z0-9]` and containing only `[A-Za-z0-9_-]`)
/// - values: `<= 255` UTF-8 bytes with no control characters (`char::is_control`)
pub fn validate_vm_labels(labels: Option<&std::collections::HashMap<String, String>>) -> Result<()> {
let Some(labels) = labels else {
return Ok(());
};
if labels.len() > 64 {
anyhow::bail!("too many VM labels (max 64)");
}
for (key, value) in labels {
validate_vm_name(key).map_err(|reason| anyhow::anyhow!("invalid VM label key {key:?}: {reason}"))?;
if value.len() > 255 {
anyhow::bail!("VM label value for {key:?} too long (max 255 bytes)");
}
if value.chars().any(char::is_control) {
anyhow::bail!("VM label value for {key:?} must not contain control characters");
}
}
Ok(())
}

/// Normalize an optional label map so an empty map `{}` is treated identically to `None`.
pub fn non_empty_labels(
labels: Option<std::collections::HashMap<String, String>>,
) -> Option<std::collections::HashMap<String, String>> {
labels.filter(|labels| !labels.is_empty())
}

#[cfg(test)]
mod tests;
55 changes: 55 additions & 0 deletions crates/capsem-service/src/naming/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,58 @@ fn session_naming_takes_the_first_free_counter() {
assert_eq!(generate_session_name(std::iter::empty::<&str>()), "vm-1");
assert_eq!(generate_session_name(["vm-1", "VM-2", "code-3"]), "vm-3");
}

#[test]
fn validate_vm_labels_accepts_valid_and_rejects_invalid_keys_values_and_counts() {
assert!(validate_vm_labels(None).is_ok());
let mut map = std::collections::HashMap::new();
map.insert("inspect-capsem-prefix".to_string(), "value".to_string());
map.insert("role_1-a".to_string(), "value".to_string());
map.insert("k".repeat(64), "v".repeat(255));
assert!(validate_vm_labels(Some(&map)).is_ok());
assert_eq!(non_empty_labels(Some(std::collections::HashMap::new())), None);
assert_eq!(non_empty_labels(Some(map.clone())), Some(map));

let mut too_many = std::collections::HashMap::new();
for i in 0..65 {
too_many.insert(format!("k{i}"), "v".to_string());
}
assert!(validate_vm_labels(Some(&too_many)).is_err());

for bad_key in [
"",
&"k".repeat(65),
"-leading",
"_leading",
"suite.name",
"a/b",
"bad key",
"bad:key",
"café",
] {
let map = std::collections::HashMap::from([(bad_key.to_string(), "ok".to_string())]);
assert!(
validate_vm_labels(Some(&map)).is_err(),
"expected error for key {bad_key:?}"
);
}

let ctrl_key = std::collections::HashMap::from([("bad\nkey".to_string(), "ok".to_string())]);
let ctrl_err = validate_vm_labels(Some(&ctrl_key)).unwrap_err().to_string();
assert!(ctrl_err.contains("\"bad\\nkey\""), "got: {ctrl_err}");
assert!(
!ctrl_err.contains('\n'),
"error must not contain raw newline: {ctrl_err:?}"
);

let long_val = std::collections::HashMap::from([("k".to_string(), "v".repeat(256))]);
assert!(validate_vm_labels(Some(&long_val)).is_err());

for bad_val in ["bad\nval", "bad\0val", "bad\x7fval", "bad\u{0085}val"] {
let map = std::collections::HashMap::from([("k".to_string(), bad_val.to_string())]);
let err = validate_vm_labels(Some(&map))
.expect_err("control characters in label values must be rejected")
.to_string();
assert!(err.contains("control characters"), "got: {err}");
}
}
3 changes: 3 additions & 0 deletions crates/capsem-service/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ pub struct PersistentVmEntry {
/// guest sees the same environment after stop+resume cycles.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub env: Option<HashMap<String, String>>,
/// Key-value metadata labels attached at creation.
#[serde(skip_serializing_if = "Option::is_none", default)]
pub labels: Option<HashMap<String, String>>,
}

#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
Expand Down
1 change: 1 addition & 0 deletions crates/capsem-service/src/registry/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ fn make_entry(name: &str, session_dir: PathBuf) -> PersistentVmEntry {
last_error: None,
checkpoint_path: None,
env: None,
labels: None,
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/capsem-service/src/sandbox_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub(super) fn running_sandbox_info(i: &InstanceInfo) -> SandboxInfo {
info.cpus = Some(i.cpus);
info.version = Some(i.base_version.clone());
info.forked_from = i.forked_from.clone();
info.labels = non_empty_labels(i.labels.clone());
info.uptime_secs = Some(i.start_time.elapsed().as_secs());
info.can_resume = false;
info.refresh_available_actions();
Expand All @@ -32,6 +33,7 @@ pub(super) fn inactive_sandbox_info(
info.version = Some(entry.base_version.clone());
info.forked_from = entry.forked_from.clone();
info.description = entry.description.clone();
info.labels = non_empty_labels(entry.labels.clone());
info.can_resume = can_resume;
if can_resume {
info.resume_blocked_reason = None;
Expand Down
18 changes: 18 additions & 0 deletions crates/capsem-service/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ pub(crate) fn test_instance() -> InstanceInfo {
base_version: "0.0.0".into(),
persistent: false,
env: None,
labels: None,
forked_from: None,
owner_secret: String::new(),
}
Expand Down Expand Up @@ -389,6 +390,23 @@ pub(crate) fn test_persistent_entry(name: &str, session_dir: PathBuf) -> Persist
last_error: None,
checkpoint_path: None,
env: None,
labels: None,
}
}

pub(crate) fn test_provision_options<'a>(id: &'a str, name: &'a str) -> ProvisionOptions<'a> {
ProvisionOptions {
id,
name,
ram_mb: 2048,
cpus: 2,
scratch_disk_size_gb: 16,
version_override: None,
persistent: false,
env: None,
labels: None,
from: None,
description: None,
}
}

Expand Down
Loading
Loading