diff --git a/CHANGELOG.md b/CHANGELOG.md index b3f4c0ce0..2fa94118f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/config/gate.toml b/config/gate.toml index ff24189e0..e8d11afb3 100644 --- a/config/gate.toml +++ b/config/gate.toml @@ -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 diff --git a/crates/capsem-api/src/containers/tests.rs b/crates/capsem-api/src/containers/tests.rs index 27d27ff4c..db10796da 100644 --- a/crates/capsem-api/src/containers/tests.rs +++ b/crates/capsem-api/src/containers/tests.rs @@ -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()), diff --git a/crates/capsem-api/src/lifecycle.rs b/crates/capsem-api/src/lifecycle.rs index e7c2a9a9b..2cb965651 100644 --- a/crates/capsem-api/src/lifecycle.rs +++ b/crates/capsem-api/src/lifecycle.rs @@ -42,6 +42,9 @@ 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, + /// Key-value metadata labels attached to the sandbox at creation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] @@ -49,6 +52,9 @@ pub struct ForkRequest { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + /// 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>, } #[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] @@ -163,6 +169,9 @@ pub struct SandboxInfo { pub forked_from: Option, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, + /// Key-value metadata labels attached when the sandbox was created. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub labels: Option>, /// 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. @@ -239,6 +248,7 @@ impl SandboxInfo { version: None, forked_from: None, description: None, + labels: None, size_bytes: None, storage: None, session_db: None, diff --git a/crates/capsem-service/src/api/tests.rs b/crates/capsem-service/src/api/tests.rs index 60c753164..d0b996a95 100644 --- a/crates/capsem-service/src/api/tests.rs +++ b/crates/capsem-service/src/api/tests.rs @@ -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")); } @@ -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") + ); +} diff --git a/crates/capsem-service/src/instance.rs b/crates/capsem-service/src/instance.rs index bbefe7d44..dea653f50 100644 --- a/crates/capsem-service/src/instance.rs +++ b/crates/capsem-service/src/instance.rs @@ -23,6 +23,8 @@ pub(crate) struct InstanceInfo { pub(crate) env: Option>, /// Sandbox this VM was cloned from, if any pub(crate) forked_from: Option, + /// Key-value metadata labels attached at creation + pub(crate) labels: Option>, /// 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. diff --git a/crates/capsem-service/src/instance/tests.rs b/crates/capsem-service/src/instance/tests.rs index 7d4b75457..95615f4ed 100644 --- a/crates/capsem-service/src/instance/tests.rs +++ b/crates/capsem-service/src/instance/tests.rs @@ -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, }, diff --git a/crates/capsem-service/src/main.rs b/crates/capsem-service/src/main.rs index 82bb08131..79a966e2f 100644 --- a/crates/capsem-service/src/main.rs +++ b/crates/capsem-service/src/main.rs @@ -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, }; @@ -335,6 +335,7 @@ pub struct ProvisionOptions<'a> { pub version_override: Option, pub persistent: bool, pub env: Option>, + pub labels: Option>, pub from: Option, pub description: Option, } diff --git a/crates/capsem-service/src/managed_sessions.rs b/crates/capsem-service/src/managed_sessions.rs index fc90cf3a8..7ebb8f07e 100644 --- a/crates/capsem-service/src/managed_sessions.rs +++ b/crates/capsem-service/src/managed_sessions.rs @@ -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(), diff --git a/crates/capsem-service/src/naming.rs b/crates/capsem-service/src/naming.rs index 62873ba86..8877e6be9 100644 --- a/crates/capsem-service/src/naming.rs +++ b/crates/capsem-service/src/naming.rs @@ -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 @@ -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>) -> 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>, +) -> Option> { + labels.filter(|labels| !labels.is_empty()) +} + #[cfg(test)] mod tests; diff --git a/crates/capsem-service/src/naming/tests.rs b/crates/capsem-service/src/naming/tests.rs index 8a0471ee6..3bd26eba8 100644 --- a/crates/capsem-service/src/naming/tests.rs +++ b/crates/capsem-service/src/naming/tests.rs @@ -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}"); + } +} diff --git a/crates/capsem-service/src/registry.rs b/crates/capsem-service/src/registry.rs index 6b5f82504..bc47fce3f 100644 --- a/crates/capsem-service/src/registry.rs +++ b/crates/capsem-service/src/registry.rs @@ -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>, + /// Key-value metadata labels attached at creation. + #[serde(skip_serializing_if = "Option::is_none", default)] + pub labels: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] diff --git a/crates/capsem-service/src/registry/tests.rs b/crates/capsem-service/src/registry/tests.rs index c5bd9cec5..7d41534a6 100644 --- a/crates/capsem-service/src/registry/tests.rs +++ b/crates/capsem-service/src/registry/tests.rs @@ -19,6 +19,7 @@ fn make_entry(name: &str, session_dir: PathBuf) -> PersistentVmEntry { last_error: None, checkpoint_path: None, env: None, + labels: None, } } diff --git a/crates/capsem-service/src/sandbox_info.rs b/crates/capsem-service/src/sandbox_info.rs index 6d05b4cef..6bfb1bd68 100644 --- a/crates/capsem-service/src/sandbox_info.rs +++ b/crates/capsem-service/src/sandbox_info.rs @@ -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(); @@ -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; diff --git a/crates/capsem-service/src/tests.rs b/crates/capsem-service/src/tests.rs index 05df02099..1ac586597 100644 --- a/crates/capsem-service/src/tests.rs +++ b/crates/capsem-service/src/tests.rs @@ -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(), } @@ -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, } } diff --git a/crates/capsem-service/src/tests/assets_registry.rs b/crates/capsem-service/src/tests/assets_registry.rs index 26720e8c5..a18d9387c 100644 --- a/crates/capsem-service/src/tests/assets_registry.rs +++ b/crates/capsem-service/src/tests/assets_registry.rs @@ -955,18 +955,7 @@ fn provision_accepts_name_just_under_uds_limit() { // One byte shorter than the limit -- should pass path validation let name_len = sun_path_max - prefix - suffix_len - 1; let ok_name = "x".repeat(name_len); - let result = state.provision_sandbox(ProvisionOptions { - id: &ok_name, - name: &ok_name, - ram_mb: 2048, - cpus: 2, - scratch_disk_size_gb: 16, - version_override: None, - persistent: false, - env: None, - from: None, - description: None, - }); + let result = state.provision_sandbox(test_provision_options(&ok_name, &ok_name)); // Will fail later (missing rootfs), but NOT for path length if let Err(e) = &result { let msg = e.to_string(); @@ -980,18 +969,7 @@ fn provision_accepts_name_just_under_uds_limit() { #[test] fn provision_short_name_passes_path_check() { let state = make_test_state(); - let result = state.provision_sandbox(ProvisionOptions { - id: "my-vm", - name: "my-vm", - ram_mb: 2048, - cpus: 2, - scratch_disk_size_gb: 16, - version_override: None, - persistent: false, - env: None, - from: None, - description: None, - }); + let result = state.provision_sandbox(test_provision_options("my-vm", "my-vm")); // Fails for missing assets, not path length if let Err(e) = &result { let msg = e.to_string(); @@ -1005,18 +983,7 @@ fn provision_short_name_passes_path_check() { #[test] fn provision_without_a_manifest_fails_before_session_state() { let (state, _dir) = make_test_state_with_tempdir(); - let result = state.provision_sandbox(ProvisionOptions { - id: "my-vm", - name: "my-vm", - ram_mb: 2048, - cpus: 2, - scratch_disk_size_gb: 16, - version_override: None, - persistent: false, - env: None, - from: None, - description: None, - }); + let result = state.provision_sandbox(test_provision_options("my-vm", "my-vm")); let err = result.unwrap_err().to_string(); assert!( err.contains("no asset manifest is installed"), @@ -1046,16 +1013,8 @@ fn provision_persistent_rejects_duplicate_name() { ); } let result = state.provision_sandbox(ProvisionOptions { - id: "taken", - name: "taken", - ram_mb: 2048, - cpus: 2, - scratch_disk_size_gb: 16, - version_override: None, persistent: true, - env: None, - from: None, - description: None, + ..test_provision_options("taken", "taken") }); assert!(result.is_err()); let err = result.unwrap_err().to_string(); diff --git a/crates/capsem-service/src/tests/files_api.rs b/crates/capsem-service/src/tests/files_api.rs index a89d178e4..96a1f09d6 100644 --- a/crates/capsem-service/src/tests/files_api.rs +++ b/crates/capsem-service/src/tests/files_api.rs @@ -96,6 +96,7 @@ pub(super) fn setup_vm_with_workspace_and_uds( base_version: "0.0.0".into(), persistent: false, env: None, + labels: None, forked_from: None, owner_secret: String::new(), }, @@ -757,3 +758,207 @@ async fn exec_timeout_above_the_ceiling_is_refused_before_touching_the_vm() { .await; assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); } + +#[tokio::test] +async fn handle_provision_attaches_labels_and_list_fingerprint_tracks_label_changes() { + let dir = tempfile::tempdir().unwrap(); + let script = dir.path().join("fake-capsem-process"); + std::fs::write( + &script, + "#!/bin/sh\nwhile [ \"$#\" -gt 0 ]; do\n if [ \"$1\" = \"--uds-path\" ]; then : > \"${2%.sock}.launched\"; shift 2; else shift; fi\ndone\nexec sleep 30\n", + ) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + let mut owned = make_test_state_owned(); + owned.assets_dir = dir.path().join("assets"); + owned.process_binary = script; + let state = Arc::new(owned); + install_test_runtime_assets(&state); + + let labels = HashMap::from([ + ("suite".to_string(), "eval".to_string()), + ("inspect-capsem-prefix".to_string(), "pass-1".to_string()), + ]); + let Json(created) = handle_provision( + State(Arc::clone(&state)), + Json(ProvisionRequest { + name: Some("labeled-vm".into()), + persistent: true, + ram_mb: None, + cpus: None, + env: None, + labels: Some(labels.clone()), + from: None, + networks: Vec::new(), + container: None, + }), + ) + .await + .expect("handle_provision with valid labels should succeed"); + + let list: ListResponse = decode_response_json(handle_list(State(Arc::clone(&state))).await).await; + let listed = list.sandboxes.iter().find(|s| s.id == created.id).unwrap(); + assert_eq!(listed.labels.as_ref(), Some(&labels)); + + let Json(info) = handle_info(State(Arc::clone(&state)), Path(created.id.clone())) + .await + .unwrap(); + assert_eq!(info.labels.as_ref(), Some(&labels)); + assert_eq!( + state + .persistent_registry + .lock() + .unwrap() + .get("labeled-vm") + .and_then(|e| e.labels.clone()), + Some(labels.clone()) + ); + + let fp_before = list_response_fingerprint(&state); + state.instances.lock().unwrap().get_mut(&created.id).unwrap().labels = + Some(HashMap::from([("suite".to_string(), "other".to_string())])); + let fp_after = list_response_fingerprint(&state); + assert_ne!( + fp_before, fp_after, + "changing labels must change list_response_fingerprint" + ); + + let _ = shutdown_vm_process(&state, &created.id, ShutdownMode::Discard, None).await; + if let Some(entry) = state.persistent_registry.lock().unwrap().get_mut("labeled-vm") { + entry.defunct = false; + entry.last_error = None; + } + + let stopped_list: ListResponse = decode_response_json(handle_list(State(Arc::clone(&state))).await).await; + let stopped_listed = stopped_list.sandboxes.iter().find(|s| s.id == created.id).unwrap(); + assert_eq!( + stopped_listed.labels.as_ref(), + Some(&labels), + "stopped persistent VM in /vms/list must preserve labels" + ); + let Json(stopped_info) = handle_info(State(Arc::clone(&state)), Path(created.id.clone())) + .await + .unwrap(); + assert_eq!( + stopped_info.labels.as_ref(), + Some(&labels), + "stopped persistent VM in /vms/{{id}}/info must preserve labels" + ); + + let fp_stopped_before = list_response_fingerprint(&state); + state + .persistent_registry + .lock() + .unwrap() + .get_mut("labeled-vm") + .unwrap() + .labels = Some(HashMap::from([("suite".to_string(), "stopped-other".to_string())])); + let fp_stopped_after = list_response_fingerprint(&state); + assert_ne!( + fp_stopped_before, fp_stopped_after, + "changing stopped persistent entry labels must change list_response_fingerprint" + ); + + if let Some(entry) = state.persistent_registry.lock().unwrap().get_mut("labeled-vm") { + let rootfs = capsem_core::session::system_overlay_image_path(&entry.session_dir); + std::fs::create_dir_all(rootfs.parent().unwrap()).unwrap(); + std::fs::File::create(rootfs) + .unwrap() + .set_len(64 * 1024 * 1024 * 1024) + .unwrap(); + entry.labels = Some(labels.clone()); + entry.defunct = false; + entry.last_error = None; + } + assert_eq!(state.resume_sandbox(&created.id, None, None).unwrap(), created.id); + assert_eq!( + state + .instances + .lock() + .unwrap() + .get(&created.id) + .and_then(|i| i.labels.clone()), + Some(labels), + "resumed InstanceInfo must preserve persistent entry labels" + ); + let _ = shutdown_vm_process(&state, &created.id, ShutdownMode::Discard, None).await; + + let Json(empty_created) = handle_provision( + State(Arc::clone(&state)), + Json(ProvisionRequest { + name: Some("empty-labels-vm".into()), + persistent: true, + ram_mb: None, + cpus: None, + env: None, + labels: Some(HashMap::new()), + from: None, + networks: Vec::new(), + container: None, + }), + ) + .await + .expect("handle_provision with empty labels should succeed"); + let Json(empty_info) = handle_info(State(Arc::clone(&state)), Path(empty_created.id.clone())) + .await + .unwrap(); + assert_eq!(empty_info.labels, None, "empty labels map must normalize to None"); + assert_eq!( + state + .persistent_registry + .lock() + .unwrap() + .get("empty-labels-vm") + .and_then(|e| e.labels.clone()), + None + ); + let _ = shutdown_vm_process(&state, &empty_created.id, ShutdownMode::Discard, None).await; +} + +#[tokio::test] +async fn handle_provision_rejects_invalid_labels_with_bad_request() { + let (state, _dir) = make_test_state_with_tempdir(); + let mut too_many = HashMap::new(); + for i in 0..65 { + too_many.insert(format!("k{i}"), "v".to_string()); + } + let invalid_cases = [ + too_many, + HashMap::from([(String::new(), "v".to_string())]), + HashMap::from([("k".repeat(65), "v".to_string())]), + HashMap::from([("-leading".to_string(), "v".to_string())]), + HashMap::from([("dot.key".to_string(), "v".to_string())]), + HashMap::from([("bad key".to_string(), "v".to_string())]), + HashMap::from([("k".to_string(), "v".repeat(256))]), + HashMap::from([("k".to_string(), "bad\nval".to_string())]), + ]; + for bad_labels in invalid_cases { + let err = handle_provision( + State(Arc::clone(&state)), + Json(ProvisionRequest { + name: None, + persistent: false, + ram_mb: None, + cpus: None, + env: None, + labels: Some(bad_labels.clone()), + from: None, + networks: Vec::new(), + container: None, + }), + ) + .await + .expect_err("invalid labels must be rejected"); + assert_eq!( + err.0, + StatusCode::BAD_REQUEST, + "expected 400 for {bad_labels:?}: {}", + err.1 + ); + } +} diff --git a/crates/capsem-service/src/tests/fork.rs b/crates/capsem-service/src/tests/fork.rs index 03fd81644..8d329dbb2 100644 --- a/crates/capsem-service/src/tests/fork.rs +++ b/crates/capsem-service/src/tests/fork.rs @@ -17,6 +17,7 @@ fn running_fork_source(state: &Arc, dir: &tempfile::TempDir, refus name: "fork-src".into(), uds_path: uds_path.clone(), session_dir: session_dir.clone(), + labels: Some(HashMap::from([("suite".into(), "eval".into())])), ..test_instance() }, ); @@ -34,6 +35,7 @@ async fn handle_fork_creates_persistent_sandbox() { Json(ForkRequest { name: "my-fork".into(), description: Some("test".into()), + labels: None, }), ) .await @@ -50,6 +52,11 @@ async fn handle_fork_creates_persistent_sandbox() { assert_eq!(entry.asset_pins, test_asset_pins()); assert_eq!(entry.forked_from, Some("fork-src".into())); assert_eq!(entry.description, Some("test".into())); + assert_eq!( + entry.labels, + Some(HashMap::from([("suite".into(), "eval".into())])), + "forked VM must inherit source labels by default" + ); assert_eq!(entry.base_version, "0.0.0"); // The owner cloned into the directory the service registered. assert_eq!( @@ -72,6 +79,7 @@ async fn a_fork_whose_guest_will_not_freeze_fails_and_leaves_nothing() { Json(ForkRequest { name: "my-fork".into(), description: None, + labels: None, }), ) .await @@ -98,6 +106,7 @@ async fn handle_fork_not_found() { Json(ForkRequest { name: "img".into(), description: None, + labels: None, }), ) .await @@ -132,6 +141,7 @@ async fn handle_fork_duplicate_returns_conflict() { Json(ForkRequest { name: "same-name".into(), description: None, + labels: None, }), ) .await @@ -143,6 +153,7 @@ async fn handle_fork_duplicate_returns_conflict() { Json(ForkRequest { name: "same-name".into(), description: None, + labels: None, }), ) .await @@ -166,6 +177,7 @@ async fn handle_fork_from_persistent_registry() { PersistentVmEntry { id: vm_id.clone(), created_at: "2026-01-01T00:00:00Z".into(), + labels: Some(HashMap::from([("suite".into(), "eval".into())])), ..test_persistent_entry("pers-vm", session_dir.clone()) }, ); @@ -173,10 +185,11 @@ async fn handle_fork_from_persistent_registry() { // state is already Arc from make_test_state* let result = handle_fork( State(state.clone()), - Path(vm_id), + Path(vm_id.clone()), Json(ForkRequest { name: "from-pers".into(), description: None, + labels: None, }), ) .await @@ -189,7 +202,72 @@ async fn handle_fork_from_persistent_registry() { assert_eq!(entry.asset_pins, test_asset_pins()); assert!(entry.legacy_profile_id.is_none()); assert_eq!(entry.asset_pins, test_asset_pins()); + assert_eq!( + entry.labels, + Some(HashMap::from([("suite".into(), "eval".into())])), + "forked persistent VM must inherit source labels by default" + ); drop(registry); + + // Overriding labels on fork replaces source labels; empty map `{}` clears them to `None`. + let overridden = HashMap::from([("suite".into(), "override".into())]); + let _ = handle_fork( + State(state.clone()), + Path(vm_id.clone()), + Json(ForkRequest { + name: "from-pers-override".into(), + description: None, + labels: Some(overridden.clone()), + }), + ) + .await + .unwrap(); + assert_eq!( + state + .persistent_registry + .lock() + .unwrap() + .get("from-pers-override") + .unwrap() + .labels, + Some(overridden) + ); + + let _ = handle_fork( + State(state.clone()), + Path(vm_id.clone()), + Json(ForkRequest { + name: "from-pers-cleared".into(), + description: None, + labels: Some(HashMap::new()), + }), + ) + .await + .unwrap(); + assert_eq!( + state + .persistent_registry + .lock() + .unwrap() + .get("from-pers-cleared") + .unwrap() + .labels, + None, + "empty labels map on fork must clear inherited labels to None" + ); + + let bad = handle_fork( + State(state.clone()), + Path(vm_id), + Json(ForkRequest { + name: "from-pers-bad".into(), + description: None, + labels: Some(HashMap::from([("dot.key".into(), "v".into())])), + }), + ) + .await + .unwrap_err(); + assert_eq!(bad.0, StatusCode::BAD_REQUEST, "{}", bad.1); } /// A VM from before profiles were removed is refused, not laundered into a @@ -219,6 +297,7 @@ async fn handle_fork_refuses_a_profile_era_vm() { Json(ForkRequest { name: "blocked-fork".into(), description: None, + labels: None, }), ) .await diff --git a/crates/capsem-service/src/tests/instance_reaper.rs b/crates/capsem-service/src/tests/instance_reaper.rs index 87645d4e0..ede94340b 100644 --- a/crates/capsem-service/src/tests/instance_reaper.rs +++ b/crates/capsem-service/src/tests/instance_reaper.rs @@ -88,6 +88,7 @@ fn provision_persistent_validates_name() { version_override: None, persistent: true, env: None, + labels: None, from: None, description: None, }); @@ -119,6 +120,7 @@ fn accepted_restart_refuses_both_launch_paths_before_any_session_mutation() { version_override: None, persistent: true, env: None, + labels: None, from: None, description: None, }); diff --git a/crates/capsem-service/src/tests/lifecycle.rs b/crates/capsem-service/src/tests/lifecycle.rs index c8eec9dfd..21cbb5c0f 100644 --- a/crates/capsem-service/src/tests/lifecycle.rs +++ b/crates/capsem-service/src/tests/lifecycle.rs @@ -173,16 +173,8 @@ fn persistent_resume_allows_deprecated_pins_but_blocks_explicit_revocation() { fn provision_rejects_nonexistent_source_sandbox() { let (state, _dir) = make_test_state_with_tempdir(); let result = state.provision_sandbox(ProvisionOptions { - id: "vm1", - name: "vm1", - ram_mb: 2048, - cpus: 2, - scratch_disk_size_gb: 16, - version_override: None, - persistent: false, - env: None, from: Some(crate::CloneFrom::keeping_image("ghost-sandbox")), - description: None, + ..test_provision_options("vm1", "vm1") }); assert!(result.is_err()); let err = result.unwrap_err().to_string(); @@ -195,16 +187,8 @@ fn provision_refuses_to_clone_a_profile_era_vm() { install_test_runtime_assets(&state); register_entry(&state, profile_era_entry(&state, "profile-era-source")); let result = state.provision_sandbox(ProvisionOptions { - id: "vm1", - name: "vm1", - ram_mb: 2048, - cpus: 2, - scratch_disk_size_gb: 16, - version_override: None, - persistent: false, - env: None, from: Some(crate::CloneFrom::keeping_image("profile-era-source")), - description: None, + ..test_provision_options("vm1", "vm1") }); let err = result.unwrap_err().to_string(); assert!( @@ -292,21 +276,9 @@ async fn handle_info_shows_suspended_status() { "info-susp".into(), PersistentVmEntry { id: vm_id.clone(), - name: "info-susp".into(), - legacy_profile_id: None, - asset_pins: test_asset_pins(), - ram_mb: 2048, - cpus: 2, - base_version: "0.0.0".into(), - created_at: "0".into(), - session_dir, - forked_from: None, - description: None, suspended: true, - defunct: false, - last_error: None, checkpoint_path: Some("checkpoint.vzsave".into()), - env: None, + ..test_persistent_entry("info-susp", session_dir) }, ); } @@ -576,21 +548,9 @@ fn existing_resume_checkpoint_requires_completion_marker() { "resume-vm".into(), PersistentVmEntry { id: vm_id.clone(), - name: "resume-vm".into(), - legacy_profile_id: None, - asset_pins: test_asset_pins(), - ram_mb: 2048, - cpus: 2, - base_version: "0.0.0".into(), - created_at: "0".into(), - session_dir, - forked_from: None, - description: None, suspended: true, - defunct: false, - last_error: None, checkpoint_path: Some("checkpoint.vzsave".into()), - env: None, + ..test_persistent_entry("resume-vm", session_dir) }, ); } @@ -623,21 +583,9 @@ fn clear_resume_checkpoint_removes_completion_marker() { "resume-vm".into(), PersistentVmEntry { id: vm_id.clone(), - name: "resume-vm".into(), - legacy_profile_id: None, - asset_pins: test_asset_pins(), - ram_mb: 2048, - cpus: 2, - base_version: "0.0.0".into(), - created_at: "0".into(), - session_dir, - forked_from: None, - description: None, suspended: true, - defunct: false, - last_error: None, checkpoint_path: Some("checkpoint.vzsave".into()), - env: None, + ..test_persistent_entry("resume-vm", session_dir) }, ); } diff --git a/crates/capsem-service/src/tests/network_routes.rs b/crates/capsem-service/src/tests/network_routes.rs index 429e2fafc..39aa3ae98 100644 --- a/crates/capsem-service/src/tests/network_routes.rs +++ b/crates/capsem-service/src/tests/network_routes.rs @@ -199,6 +199,7 @@ async fn stopping_a_vm_keeps_its_membership_and_a_fork_has_none() { Json(ForkRequest { name: "my-fork".into(), description: None, + labels: None, }), ) .await diff --git a/crates/capsem-service/src/tests/settings_files.rs b/crates/capsem-service/src/tests/settings_files.rs index 6c93706f2..43f044902 100644 --- a/crates/capsem-service/src/tests/settings_files.rs +++ b/crates/capsem-service/src/tests/settings_files.rs @@ -200,6 +200,7 @@ fn resolve_rejects_symlink_escape() { base_version: "0.0.0".into(), persistent: false, env: None, + labels: None, forked_from: None, owner_secret: String::new(), }, @@ -234,6 +235,7 @@ fn resolve_valid_path_inside_workspace() { base_version: "0.0.0".into(), persistent: false, env: None, + labels: None, forked_from: None, owner_secret: String::new(), }, diff --git a/crates/capsem-service/src/vm_files.rs b/crates/capsem-service/src/vm_files.rs index a16c239ec..bc5175853 100644 --- a/crates/capsem-service/src/vm_files.rs +++ b/crates/capsem-service/src/vm_files.rs @@ -678,6 +678,7 @@ pub(super) async fn provision_attempt( scratch_disk_size_gb: u32, persistent: bool, env: Option>, + labels: Option>, from: Option, ) -> ProvisionAttemptOutcome { // Creating/starting a VM is an Apple VZ lifecycle operation too. Cold @@ -708,6 +709,7 @@ pub(super) async fn provision_attempt( version_override: Some(version), persistent, env, + labels, from, description: None, }) @@ -773,6 +775,19 @@ pub(super) fn append_fingerprint_field(out: &mut String, value: &str) { out.push('|'); } +pub(super) fn append_labels_fingerprint(out: &mut String, labels: Option<&HashMap>) { + use std::fmt::Write as _; + + let mut pairs: Vec<_> = labels.into_iter().flatten().collect(); + pairs.sort_unstable(); + let _ = write!(out, "labels={};", pairs.len()); + for (k, v) in pairs { + append_fingerprint_field(out, k); + append_fingerprint_field(out, v); + } +} + +/// Cheap key over every field /vms/list returns; list_lifecycle reuses the cached response while it is unchanged. pub(super) fn list_response_fingerprint(state: &ServiceState) -> String { use std::fmt::Write as _; @@ -794,6 +809,7 @@ pub(super) fn list_response_fingerprint(state: &ServiceState) -> String { ); append_fingerprint_field(&mut fingerprint, &i.base_version); append_fingerprint_field(&mut fingerprint, i.forked_from.as_deref().unwrap_or("")); + append_labels_fingerprint(&mut fingerprint, i.labels.as_ref()); } } { @@ -810,6 +826,7 @@ pub(super) fn list_response_fingerprint(state: &ServiceState) -> String { append_fingerprint_field(&mut fingerprint, entry.legacy_profile_id.as_deref().unwrap_or("")); append_fingerprint_field(&mut fingerprint, &entry.base_version); append_fingerprint_field(&mut fingerprint, entry.forked_from.as_deref().unwrap_or("")); + append_labels_fingerprint(&mut fingerprint, entry.labels.as_ref()); append_fingerprint_field(&mut fingerprint, entry.description.as_deref().unwrap_or("")); append_fingerprint_field(&mut fingerprint, entry.last_error.as_deref().unwrap_or("")); let _ = write!( diff --git a/crates/capsem-service/src/vm_files/fork.rs b/crates/capsem-service/src/vm_files/fork.rs index ce85c9220..2d14ca962 100644 --- a/crates/capsem-service/src/vm_files/fork.rs +++ b/crates/capsem-service/src/vm_files/fork.rs @@ -13,6 +13,7 @@ pub(crate) async fn handle_fork( .map_err(|e| AppError(StatusCode::CONFLICT, e.to_string()))?; let name = &payload.name; validate_vm_name(name).map_err(|e| AppError(StatusCode::BAD_REQUEST, e.to_string()))?; + validate_vm_labels(payload.labels.as_ref()).map_err(|e| AppError(StatusCode::BAD_REQUEST, e.to_string()))?; // Check name is not taken { @@ -27,7 +28,7 @@ pub(crate) async fn handle_fork( // Find source: running instance or stopped persistent VM. A fork boots // the source's own images, so it inherits the source's asset pins. - let (session_dir, asset_pins, ram_mb, cpus, base_version, uds_path) = { + let (session_dir, asset_pins, ram_mb, cpus, base_version, source_labels, uds_path) = { let instances = state.instances.lock().unwrap(); if let Some(i) = instances.get(&id) { ( @@ -36,6 +37,7 @@ pub(crate) async fn handle_fork( i.ram_mb, i.cpus, i.base_version.clone(), + i.labels.clone(), Some(i.uds_path.clone()), ) } else { @@ -50,9 +52,21 @@ pub(crate) async fn handle_fork( state .validate_persistent_entry(&p) .map_err(|e| AppError(StatusCode::PRECONDITION_FAILED, e.to_string()))?; - (p.session_dir, p.asset_pins, p.ram_mb, p.cpus, p.base_version, None) + ( + p.session_dir, + p.asset_pins, + p.ram_mb, + p.cpus, + p.base_version, + p.labels, + None, + ) } }; + let labels = match payload.labels { + Some(labels) => non_empty_labels(Some(labels)), + None => non_empty_labels(source_labels), + }; // Clone state into new persistent sandbox. The route/runtime id is // separate from the human display name. @@ -88,6 +102,7 @@ pub(crate) async fn handle_fork( last_error: None, checkpoint_path: None, env: None, + labels, }; state .off_worker(move |state| state.persistent_registry.lock().unwrap().register(entry)) diff --git a/crates/capsem-service/src/vm_files/provision.rs b/crates/capsem-service/src/vm_files/provision.rs index 1e4560b15..e7a9a6c06 100644 --- a/crates/capsem-service/src/vm_files/provision.rs +++ b/crates/capsem-service/src/vm_files/provision.rs @@ -8,6 +8,8 @@ pub(crate) async fn handle_provision( .lifecycle .admit() .map_err(|e| AppError(StatusCode::CONFLICT, e.to_string()))?; + validate_vm_labels(payload.labels.as_ref()).map_err(|e| AppError(StatusCode::BAD_REQUEST, e.to_string()))?; + let labels = non_empty_labels(payload.labels.clone()); if let Some(reason) = state.off_worker(|state| vm_asset_block_reason(&state)).await? { return Err(AppError(StatusCode::PRECONDITION_FAILED, reason)); } @@ -54,6 +56,7 @@ pub(crate) async fn handle_provision( let id = id_for_loop.clone(); let name = name.clone(); let payload_env = capsem_core::container::session_env(payload.env.clone(), payload.container.is_some()); + let payload_labels = labels.clone(); let payload_from = payload.from.clone().map(|source| crate::CloneFrom { source, replace_image: payload.container.is_some(), @@ -88,6 +91,7 @@ pub(crate) async fn handle_provision( scratch_disk_size_gb, payload_persistent, payload_env, + payload_labels, payload_from, ) .await; diff --git a/crates/capsem-service/src/vm_lifecycle.rs b/crates/capsem-service/src/vm_lifecycle.rs index 191b52d27..7855d4056 100644 --- a/crates/capsem-service/src/vm_lifecycle.rs +++ b/crates/capsem-service/src/vm_lifecycle.rs @@ -643,7 +643,7 @@ pub(super) async fn handle_persist( validate_vm_name(name).map_err(|e| AppError(StatusCode::BAD_REQUEST, e.to_string()))?; // Find the running ephemeral instance - let (live_session_dir, asset_pins, ram_mb, cpus, base_version, forked_from, env) = { + let (live_session_dir, asset_pins, ram_mb, cpus, base_version, forked_from, env, labels) = { let instances = state.instances.lock().unwrap(); let i = instances .get(&id) @@ -662,6 +662,7 @@ pub(super) async fn handle_persist( i.base_version.clone(), i.forked_from.clone(), i.env.clone(), + i.labels.clone(), ); drop(instances); result @@ -693,6 +694,7 @@ pub(super) async fn handle_persist( last_error: None, checkpoint_path: None, env, + labels, }; let claim_state = Arc::clone(&state); tokio::task::spawn_blocking(move || claim_persistent_name(&claim_state, entry)) @@ -859,6 +861,7 @@ pub(super) async fn handle_run( version_override: Some(version), persistent: false, env, + labels: None, from: None, description: None, }) diff --git a/crates/capsem-service/src/vm_lifecycle/provision.rs b/crates/capsem-service/src/vm_lifecycle/provision.rs index 7407c5902..142467e6f 100644 --- a/crates/capsem-service/src/vm_lifecycle/provision.rs +++ b/crates/capsem-service/src/vm_lifecycle/provision.rs @@ -21,9 +21,11 @@ impl ServiceState { version_override, persistent, env, + labels, from, description, } = options; + let labels = non_empty_labels(labels); let vm_settings = capsem_core::net::policy_config::load_merged_vm_settings(); let max_concurrent_vms = vm_settings.max_concurrent_vms.unwrap_or(10) as usize; @@ -318,6 +320,7 @@ impl ServiceState { last_error: None, checkpoint_path: None, env: env.clone(), + labels: labels.clone(), }); if let Err(error) = registration { instance_reaper::kill_and_reap(child); @@ -342,6 +345,7 @@ impl ServiceState { base_version: version, persistent, env, + labels, forked_from: from_name, owner_secret, }, diff --git a/crates/capsem-service/src/vm_lifecycle/resume_process.rs b/crates/capsem-service/src/vm_lifecycle/resume_process.rs index 507ec268f..72c2e6ded 100644 --- a/crates/capsem-service/src/vm_lifecycle/resume_process.rs +++ b/crates/capsem-service/src/vm_lifecycle/resume_process.rs @@ -238,6 +238,7 @@ impl ServiceState { base_version: version, persistent: true, env: None, + labels: entry.labels, forked_from: entry.forked_from, owner_secret, }, diff --git a/crates/capsem-tui/src/sdk_actions.rs b/crates/capsem-tui/src/sdk_actions.rs index db97d94da..be949eebf 100644 --- a/crates/capsem-tui/src/sdk_actions.rs +++ b/crates/capsem-tui/src/sdk_actions.rs @@ -23,6 +23,7 @@ pub async fn invoke(hypervisor: &Hypervisor, transport: &Transport, action: &Con ram_mb: None, cpus: None, env: None, + labels: None, from: None, networks: Vec::new(), container: image.as_ref().map(|image| capsem_sdk::models::ContainerSpec { @@ -43,7 +44,7 @@ pub async fn invoke(hypervisor: &Hypervisor, transport: &Transport, action: &Con }) } ControlAction::Fork { id, name } => { - let vm = vm(id)?.fork(name, None).await?; + let vm = vm(id)?.fork(name, None, None).await?; Ok(ActionOutcome { message: format!("forked {}", vm.name().unwrap_or(name)), focus_session: vm.id().map(str::to_owned), diff --git a/crates/capsem/src/client/tests.rs b/crates/capsem/src/client/tests.rs index 90443f8c5..e800b130e 100644 --- a/crates/capsem/src/client/tests.rs +++ b/crates/capsem/src/client/tests.rs @@ -316,6 +316,7 @@ fn provision_request_serde() { cpus: Some(4), persistent: true, env: None, + labels: None, from: None, networks: Vec::new(), container: None, @@ -338,6 +339,7 @@ fn provision_request_with_env() { cpus: Some(2), persistent: true, env: Some(env), + labels: None, from: None, networks: Vec::new(), container: None, @@ -356,6 +358,7 @@ fn provision_request_env_omitted_when_none() { cpus: Some(2), persistent: false, env: None, + labels: None, from: None, networks: Vec::new(), container: None, @@ -373,6 +376,7 @@ fn provision_request_omits_unset_resources_for_the_service_defaults() { cpus: None, persistent: false, env: None, + labels: None, from: None, networks: Vec::new(), container: None, @@ -389,6 +393,7 @@ fn provision_request_with_from() { cpus: Some(2), persistent: false, env: None, + labels: None, from: Some("my-sandbox".into()), networks: Vec::new(), container: None, @@ -407,6 +412,7 @@ fn provision_request_from_omitted_when_none() { cpus: Some(2), persistent: false, env: None, + labels: None, from: None, networks: Vec::new(), container: None, @@ -581,11 +587,13 @@ fn fork_request_serde() { let req = ForkRequest { name: "my-img".into(), description: Some("test image".into()), + labels: Some(HashMap::from([("suite".into(), "eval".into())])), }; let json = serde_json::to_string(&req).unwrap(); let req2: ForkRequest = serde_json::from_str(&json).unwrap(); assert_eq!(req2.name, "my-img"); assert_eq!(req2.description, Some("test image".into())); + assert_eq!(req2.labels, Some(HashMap::from([("suite".into(), "eval".into())]))); } #[test] @@ -593,9 +601,11 @@ fn fork_request_description_omitted_when_none() { let req = ForkRequest { name: "img".into(), description: None, + labels: None, }; let json = serde_json::to_string(&req).unwrap(); assert!(!json.contains("description")); + assert!(!json.contains("labels")); } #[test] diff --git a/crates/capsem/src/container_image/tests.rs b/crates/capsem/src/container_image/tests.rs index b9c0d6ae8..cfb5286b4 100644 --- a/crates/capsem/src/container_image/tests.rs +++ b/crates/capsem/src/container_image/tests.rs @@ -33,6 +33,7 @@ async fn provision_leaves_resources_to_the_service_when_unset() { cpus: None, persistent: false, env: None, + labels: None, from: None, networks: vec![], container: None, diff --git a/crates/capsem/src/container_run.rs b/crates/capsem/src/container_run.rs index df9fb1a79..c52e0c60c 100644 --- a/crates/capsem/src/container_run.rs +++ b/crates/capsem/src/container_run.rs @@ -143,6 +143,7 @@ async fn run_image(client: &UdsClient, args: &RunArgs, workload: &Workload<'_>) cpus: args.cpu, persistent: false, env: None, + labels: None, from: None, networks: args.network.clone(), container: Some(workload.spec(true).await?), diff --git a/crates/capsem/src/container_run/tests.rs b/crates/capsem/src/container_run/tests.rs index 7b5c4456e..a3c16933e 100644 --- a/crates/capsem/src/container_run/tests.rs +++ b/crates/capsem/src/container_run/tests.rs @@ -174,6 +174,26 @@ mod against_the_service { service.route("POST", "/run", 503, json!({"error": "VM assets are not ready"})); let error = run(&service.client, &args(&["true"])).await.unwrap_err(); assert!(format!("{error:#}").contains("VM assets are not ready"), "{error:#}"); + + service + .route( + "POST", + "/vms/create", + 200, + json!({"id": "vm-9", "name": "vm-9", "status": "Running", "available_actions": []}), + ) + .route( + "GET", + "/vms/vm-9/container", + 200, + json!({"state": "failed", "image": "docker://redis:7", "error": "pull failed"}), + ) + .route("DELETE", "/vms/vm-9/delete", 200, json!({"success": true})); + let error = run(&service.client, &args(&["--image", "docker://redis:7"])) + .await + .unwrap_err(); + assert!(format!("{error:#}").contains("pull failed"), "{error:#}"); + assert_eq!(service.find("DELETE", "/vms/vm-9/delete").len(), 1); } } diff --git a/crates/capsem/src/create_command.rs b/crates/capsem/src/create_command.rs index 5f233c279..331c651a5 100644 --- a/crates/capsem/src/create_command.rs +++ b/crates/capsem/src/create_command.rs @@ -3,7 +3,7 @@ use anyhow::Result; -use crate::client::{self, ProvisionRequest, ProvisionResponse, UdsClient}; +use crate::client::{self, ApiResponse, ForkRequest, ForkResponse, ProvisionRequest, ProvisionResponse, UdsClient}; use crate::container_image::{self, ImageArgs, Workload}; use crate::container_run::ram_mb; use capsem_api::ContainerState; @@ -22,6 +22,9 @@ pub(super) struct CreateArgs { /// Set environment variables (repeatable: -e KEY=VALUE; the container's, with --image) #[arg(short = 'e', long = "env")] pub env: Vec, + /// Attach advisory metadata labels at creation (repeatable: -l KEY=VALUE) + #[arg(short = 'l', long = "label")] + pub label: Vec, /// Clone state from an existing persistent session: its workspace and, /// with --image, its image volumes under the new image; without, its image #[arg(long)] @@ -36,6 +39,7 @@ pub(super) struct CreateArgs { pub(super) async fn create(client: &UdsClient, args: &CreateArgs) -> Result<()> { let persistent = args.name.is_some() || args.from.is_some(); let workload = Workload::of(&args.image, &args.env)?; + let labels = client::parse_env_vars(&args.label)?; let request = ProvisionRequest { name: args.name.clone(), ram_mb: ram_mb(args.ram), @@ -46,6 +50,7 @@ pub(super) async fn create(client: &UdsClient, args: &CreateArgs) -> Result<()> None => client::parse_env_vars(&args.env)?, Some(_) => None, }, + labels, from: args.from.clone(), networks: args.network.clone(), container: match &workload { @@ -65,6 +70,27 @@ pub(super) async fn create(client: &UdsClient, args: &CreateArgs) -> Result<()> Ok(()) } +pub(super) async fn fork( + client: &UdsClient, + session: &str, + name: &str, + description: Option<&str>, + label: &[String], +) -> Result<()> { + client::validate_id(session)?; + let session_id = crate::route_ids::resolve_session_route_id(client, session).await?; + let req = ForkRequest { + name: name.to_owned(), + description: description.map(ToOwned::to_owned), + labels: client::parse_env_vars(label)?, + }; + let resp: ApiResponse = client.post(&format!("/vms/{session_id}/fork"), &req).await?; + let info = resp.into_result()?; + let size_mb = info.size_bytes as f64 / 1024.0 / 1024.0; + println!("Forked session '{}' from '{session}' ({size_mb:.1} MB)", info.name); + Ok(()) +} + /// Follow the service until the workload is launched, then publish its /// ports. A VM this command could not start is not left behind. async fn start_image(client: &UdsClient, vm: &ProvisionResponse, workload: &Workload<'_>) -> Result<()> { diff --git a/crates/capsem/src/create_command/tests.rs b/crates/capsem/src/create_command/tests.rs index d30be1ba9..c8a361b6e 100644 --- a/crates/capsem/src/create_command/tests.rs +++ b/crates/capsem/src/create_command/tests.rs @@ -77,9 +77,12 @@ mod against_the_service { let service = FakeService::start(); service.route("POST", "/vms/create", 200, created("vm-1")); - create(&service.client, &args(&["-e", "A=1", "--network", "team"])) - .await - .unwrap(); + create( + &service.client, + &args(&["-e", "A=1", "-l", "suite=eval", "--network", "team"]), + ) + .await + .unwrap(); create(&service.client, &args(&["-n", "keep", "--ram", "2", "--cpu", "3"])) .await .unwrap(); @@ -92,7 +95,7 @@ mod against_the_service { .collect(); assert_eq!( bodies[0], - json!({"name": null, "persistent": false, "env": {"A": "1"}, "networks": ["team"]}) + json!({"name": null, "persistent": false, "env": {"A": "1"}, "labels": {"suite": "eval"}, "networks": ["team"]}) ); assert_eq!( bodies[1], @@ -186,14 +189,72 @@ mod against_the_service { ); assert!(service.find("DELETE", "/vms/vm-3/delete").is_empty()); } + + #[tokio::test] + async fn create_and_fork_reject_malformed_labels_and_dispatch_fork_labels() { + let service = FakeService::start(); + service + .route( + "GET", + "/vms/list", + 200, + json!({"sandboxes": [{"id": "vm-1", "name": "vm-1", "pid": 1, "status": "Running", "persistent": true}]}), + ) + .route( + "POST", + "/vms/vm-1/fork", + 200, + json!({"id": "vm-2", "name": "img-1", "size_bytes": 1048576}), + ); + + let err = create(&service.client, &args(&["-l", "malformed"])).await.unwrap_err(); + assert!(format!("{err:#}").contains("KEY=VALUE"), "{err:#}"); + assert!(service.find("POST", "/vms/create").is_empty()); + + let err = fork(&service.client, "vm-1", "img-1", None, &["malformed".into()]) + .await + .unwrap_err(); + assert!(format!("{err:#}").contains("KEY=VALUE"), "{err:#}"); + assert!(service.find("POST", "/vms/vm-1/fork").is_empty()); + + fork( + &service.client, + "vm-1", + "img-1", + Some("eval snapshot"), + &["suite=eval".into(), "tier=fast".into()], + ) + .await + .unwrap(); + assert_eq!( + service.find("POST", "/vms/vm-1/fork")[0].json(), + json!({ + "name": "img-1", + "description": "eval snapshot", + "labels": {"suite": "eval", "tier": "fast"} + }) + ); + } } #[test] fn a_clone_source_takes_a_new_image() { - let cli = Cli::parse_from(["capsem", "create", "--from", "base", "--image", "docker://redis"]); + let cli = Cli::parse_from([ + "capsem", + "create", + "--from", + "base", + "-l", + "k=v", + "--label", + "suite=eval", + "--image", + "docker://redis", + ]); let Commands::Session(SessionCommands::Create(args)) = cli.command.unwrap() else { panic!("expected Create") }; assert_eq!(args.from.as_deref(), Some("base")); + assert_eq!(args.label, ["k=v", "suite=eval"]); assert_eq!(args.image.image, ["docker://redis"]); } diff --git a/crates/capsem/src/main.rs b/crates/capsem/src/main.rs index 02f525f32..52fcd6a8b 100644 --- a/crates/capsem/src/main.rs +++ b/crates/capsem/src/main.rs @@ -45,9 +45,8 @@ use tokio::io::AsyncWriteExt; #[cfg(test)] use client::UpdateTrackState; use client::{ - ApiResponse, AssetStatus, ForkRequest, ForkResponse, HistoryResponse, ListResponse, LogsResponse, PersistRequest, - ProvisionRequest, ProvisionResponse, PurgeRequest, PurgeResponse, SessionInfo, UdsClient, UpdateStatusResponse, - VmLifecycleState, + ApiResponse, AssetStatus, HistoryResponse, ListResponse, LogsResponse, PersistRequest, ProvisionRequest, + ProvisionResponse, PurgeRequest, PurgeResponse, SessionInfo, UdsClient, UpdateStatusResponse, VmLifecycleState, }; const DOCTOR_MOCK_SERVER_ADDR: &str = "127.0.0.1:3713"; @@ -410,6 +409,9 @@ enum SessionCommands { /// Optional description #[arg(short, long)] description: Option, + /// Override metadata labels on the fork (repeatable: -l KEY=VALUE) + #[arg(short = 'l', long = "label")] + label: Vec, }, /// Promote an ephemeral session to persistent Persist { @@ -1358,18 +1360,8 @@ async fn main() -> Result<()> { session, name, description, - }) => { - client::validate_id(session)?; - let session_id = resolve_session_route_id(&client, session).await?; - let req = ForkRequest { - name: name.clone(), - description: description.clone(), - }; - let resp: ApiResponse = client.post(&format!("/vms/{}/fork", session_id), &req).await?; - let info = resp.into_result()?; - let size_mb = info.size_bytes as f64 / 1024.0 / 1024.0; - println!("Forked session '{}' from '{}' ({:.1} MB)", info.name, session, size_mb); - } + label, + }) => create_command::fork(&client, session, name, description.as_deref(), label).await?, Commands::Session(SessionCommands::Resume { name }) => { client::validate_id(name)?; let session_id = resolve_session_route_id(&client, name).await?; diff --git a/crates/capsem/src/tests.rs b/crates/capsem/src/tests.rs index c245ae793..9eb958e86 100644 --- a/crates/capsem/src/tests.rs +++ b/crates/capsem/src/tests.rs @@ -1137,10 +1137,10 @@ fn parse_fork() { session, name, description, + label, }) => { - assert_eq!(session, "my-vm"); - assert_eq!(name, "my-image"); - assert_eq!(description, None); + assert_eq!((session.as_str(), name.as_str()), ("my-vm", "my-image")); + assert!(description.is_none() && label.is_empty()); } _ => panic!("expected Fork"), } @@ -1148,16 +1148,16 @@ fn parse_fork() { #[test] fn parse_fork_with_description() { - let cli = Cli::parse_from(["capsem", "fork", "vm1", "img1", "-d", "My description"]); + let cli = Cli::parse_from("capsem fork vm1 img1 -d desc -l suite=eval".split_whitespace()); match cli.command.unwrap() { Commands::Session(SessionCommands::Fork { session, name, description, + label, }) => { - assert_eq!(session, "vm1"); - assert_eq!(name, "img1"); - assert_eq!(description, Some("My description".into())); + assert_eq!((session.as_str(), name.as_str()), ("vm1", "img1")); + assert!(description.as_deref() == Some("desc") && label == ["suite=eval"]); } _ => panic!("expected Fork"), } diff --git a/sdk/python/README.md b/sdk/python/README.md index 5bb3aac7f..fa6e3399f 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -84,14 +84,19 @@ async with VM("http://127.0.0.1:19222", token, name="workspace") as vm: info = await vm.info() # includes AI/model/MCP, network and file information ``` -Named VMs are persistent; an omitted name creates an ephemeral VM. Omitting -`cpus` or `memory` uses the service defaults (4 CPUs, 12 GiB). Memory is a -positive integer in GiB. - -`hv.list()` returns a typed VM inventory. `hv.update()` applies the configured -update. VM lifecycle methods are `start`, `stop`, `pause`, `resume`, `delete` -and `fork(name)`. A fork returns another `VM` handle. Stats has `summary()` and -`details()`. +Named VMs are persistent; an omitted name creates an ephemeral VM. Pass +`labels={"suite": "eval", ...}` to `create(...)` to attach advisory string +labels (`<= 64` entries; keys `1..=64` ASCII matching the VM-name rule +`[A-Za-z0-9][A-Za-z0-9_-]{0,63}`; values `<= 255` UTF-8 bytes without control +characters). Omitting `cpus` or `memory` uses the service defaults (4 CPUs, +12 GiB). Memory is a positive integer in GiB. + +`hv.list()` returns a typed VM inventory (including `labels` on each +`SandboxInfo`). `hv.update()` applies the configured update. VM lifecycle +methods are `start`, `stop`, `pause`, `resume`, `delete` and +`fork(name, *, description=..., labels=...)` (inherits source labels by +default; `labels={}` clears them). A fork returns another `VM` handle. Stats +has `summary()` and `details()`. `vm.exec(command, target=models.ExecTarget.VM)` explicitly selects VM diagnostics; `target=models.ExecTarget.WORKLOAD` selects the OCI workload. Import `models` diff --git a/sdk/python/capsem/hypervisor.py b/sdk/python/capsem/hypervisor.py index 4cacfdafd..7b83ef711 100644 --- a/sdk/python/capsem/hypervisor.py +++ b/sdk/python/capsem/hypervisor.py @@ -54,8 +54,22 @@ def vm(self, *, name: str | None = None, id: str | None = None) -> VM: async def create(self, *, name: str = "", cpus: int | None = None, memory: int | None = None, env: dict[str, str] | None = None, + labels: dict[str, str] | None = None, networks: Sequence[models.NetworkInfo] = (), image: str | None = None, command: Sequence[str] = (), registry: Registry | None = None) -> VM: + """Provision a VM or container workload. + + Args: + name: Persistent VM name; omit or pass empty string for an ephemeral VM. + cpus: Optional vCPU count override. + memory: Optional RAM size in GiB. + env: Optional environment variables for the VM or container workload. + labels: Optional string labels attached at creation. + networks: Private networks to attach to the VM at creation. + image: Optional OCI image reference to run as a container workload. + command: Optional command argv override for the container workload. + registry: Optional custom OCI registry configuration for pulling ``image``. + """ if cpus is not None and (isinstance(cpus, bool) or not isinstance(cpus, int) or cpus < 1): raise ValueError("cpus must be positive") if image is None and (command or registry is not None): @@ -79,7 +93,7 @@ async def create(self, *, name: str = "", cpus: int | None = None, request = models.ProvisionRequest( name=name or None, persistent=bool(name), cpus=cpus, ram_mb=_memory_mb(memory), - env=env if image is None else None, networks=network_names, + env=env if image is None else None, labels=labels, networks=network_names, ) if wire is not None: request.container = wire diff --git a/sdk/python/capsem/models/fork_request.py b/sdk/python/capsem/models/fork_request.py index d6722ebc8..03b5938e2 100644 --- a/sdk/python/capsem/models/fork_request.py +++ b/sdk/python/capsem/models/fork_request.py @@ -9,4 +9,5 @@ class ForkRequest(Model): description: StrictStr | None = None + labels: dict[str, StrictStr] | None = None name: StrictStr diff --git a/sdk/python/capsem/models/provision_request.py b/sdk/python/capsem/models/provision_request.py index 986279d27..d61481e4e 100644 --- a/sdk/python/capsem/models/provision_request.py +++ b/sdk/python/capsem/models/provision_request.py @@ -17,6 +17,7 @@ class ProvisionRequest(Model): cpus: Annotated[StrictInt, Field(ge=0)] | None = None env: dict[str, StrictStr] | None = None from_: StrictStr | None = Field(default=None, alias='from') + labels: dict[str, StrictStr] | None = None name: StrictStr | None = None networks: list[StrictStr] | None = None persistent: StrictBool | None = None diff --git a/sdk/python/capsem/models/sandbox_info.py b/sdk/python/capsem/models/sandbox_info.py index 72c575267..1bb6c9a34 100644 --- a/sdk/python/capsem/models/sandbox_info.py +++ b/sdk/python/capsem/models/sandbox_info.py @@ -29,6 +29,7 @@ class SandboxInfo(Model): files: VmFilesInfo | None = None forked_from: StrictStr | None = None id: StrictStr + labels: dict[str, StrictStr] | None = None last_error: StrictStr | None = None model_call_count: Annotated[StrictInt, Field(ge=0)] | None = None name: StrictStr | None = None diff --git a/sdk/python/capsem/vm.py b/sdk/python/capsem/vm.py index 1cf01b28f..cb7b12766 100644 --- a/sdk/python/capsem/vm.py +++ b/sdk/python/capsem/vm.py @@ -118,9 +118,22 @@ async def resume(self) -> models.ProvisionResponse: async def delete(self) -> models.VmActionResponse: return await api.delete_vm(self._transport, id=await self._resolve()) - async def fork(self, name: str, *, description: str | None = None) -> VM: + async def fork( + self, + name: str, + *, + description: str | None = None, + labels: dict[str, str] | None = None, + ) -> VM: + """Fork this VM into a new persistent VM. + + Args: + name: Persistent name for the forked VM. + description: Optional description for the forked VM. + labels: Optional labels overriding the source VM's labels (`{}` clears). + """ response = await api.fork_vm(self._transport, id=await self._resolve(), body=models.ForkRequest( - name=name, description=description, + name=name, description=description, labels=labels, )) return VM._bind( self._transport, id=response.id, name=response.name, container=self._has_container, diff --git a/sdk/python/tests/facade_gateway.py b/sdk/python/tests/facade_gateway.py index 04714843b..08ef66c8b 100644 --- a/sdk/python/tests/facade_gateway.py +++ b/sdk/python/tests/facade_gateway.py @@ -27,6 +27,8 @@ class GatewayState: container_states: list[str] = field(default_factory=lambda: ["running"]) preview_session_status: int | None = None delays: dict[str, float] = field(default_factory=dict) + created_vms: dict[str, tuple[str, bool]] = field(default_factory=dict) + sandbox_labels: dict[str, dict[str, str] | None] = field(default_factory=dict) def response_model(schema_name: str, **fields: Any) -> dict[str, Any]: @@ -46,11 +48,45 @@ async def handle(request: web.Request) -> web.Response: if request.path in state.delays: await asyncio.sleep(state.delays[request.path]) if request.path == "/vms/list": - return web.json_response({"sandboxes": [response_model("SandboxInfo", id=f"vm-{index}", name=name) - for index, name in enumerate(state.names)]}) + return web.json_response( + { + "sandboxes": [ + response_model( + "SandboxInfo", + id=f"vm-{index}", + name=name, + labels=state.sandbox_labels.get(f"vm-{index}"), + ) + for index, name in enumerate(state.names) + ] + } + ) if request.path == "/vms/create": payload = json.loads(body) - return web.json_response(response_model("ProvisionResponse", id="created-id", name=payload["name"] or "temporary")) + vm_name = payload["name"] or "temporary" + persistent = bool(payload.get("persistent", bool(payload.get("name") or payload.get("from")))) + state.created_vms["created-id"] = (vm_name, persistent) + state.sandbox_labels["created-id"] = payload.get("labels") or None + return web.json_response( + response_model( + "ProvisionResponse", + id="created-id", + name=vm_name, + persistent=persistent, + ) + ) + if request.path.endswith("/info") and request.path.split("/")[2] in state.created_vms: + vm_id = request.path.split("/")[2] + vm_name, persistent = state.created_vms[vm_id] + return web.json_response( + response_model( + "SandboxInfo", + id=vm_id, + name=vm_name, + persistent=persistent, + labels=state.sandbox_labels.get(vm_id), + ) + ) if request.path == "/networks" and request.method == "POST": return web.json_response(response_model("NetworkInfo", name=json.loads(body)["name"])) if request.path == "/networks": @@ -62,7 +98,15 @@ async def handle(request: web.Request) -> web.Response: if request.path == "/mcp/servers/list": return web.json_response([response_model("McpServerInfoResponse", name="filesystem")]) if request.path.endswith("/fork"): - return web.json_response(response_model("ForkResponse", id="forked-id", name=json.loads(body)["name"])) + payload = json.loads(body) + source_id = request.path.split("/")[2] + fork_id = f"forked-{payload['name']}" + state.created_vms[fork_id] = (payload["name"], True) + if "labels" in payload and payload["labels"] is not None: + state.sandbox_labels[fork_id] = payload["labels"] or None + else: + state.sandbox_labels[fork_id] = state.sandbox_labels.get(source_id) + return web.json_response(response_model("ForkResponse", id="forked-id", name=payload["name"])) if request.path.endswith("/exec"): if state.exec_status is not None: return web.Response(status=state.exec_status, text="exec target unavailable") diff --git a/sdk/python/tests/test_facade.py b/sdk/python/tests/test_facade.py index fb2c386d0..d03deb4f2 100644 --- a/sdk/python/tests/test_facade.py +++ b/sdk/python/tests/test_facade.py @@ -36,7 +36,7 @@ async def run() -> None: assert vm.id == "created-id" and vm.name == "new" body = json.loads(state.requests[-1][2]) assert body == {"name": "new", "persistent": True, - "cpus": 4, "ram_mb": 8192, "env": {"LANG": "C"}, "networks": ["team"]} + "cpus": 4, "ram_mb": 8192, "env": {"LANG": "C"}, "labels": None, "networks": ["team"]} async with vm: assert isinstance(await vm.info(), models.SandboxInfo) with pytest.raises(RuntimeError, match="closed"): diff --git a/sdk/python/tests/test_labels.py b/sdk/python/tests/test_labels.py new file mode 100644 index 000000000..e673d52f2 --- /dev/null +++ b/sdk/python/tests/test_labels.py @@ -0,0 +1,69 @@ +"""VM label propagation in the Python SDK.""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +import pytest +from capsem import VM, Hypervisor +from pydantic import ValidationError + +from .facade_gateway import gateway + + +def test_create_and_fork_propagate_labels_and_expose_them_on_sandbox_info_and_list() -> None: + async def run() -> None: + async with gateway() as (url, state), Hypervisor(url, "token") as hv: + labels = {"suite": "eval", "owner-prefix": "run_1", "k" * 64: "max"} + vm = await hv.create(labels=labels) + body = json.loads(state.requests[-1][2]) + assert body["persistent"] is False + assert body["labels"] == labels + info = await vm.info() + assert info.persistent is False + assert info.labels == labels + + # Fork without labels sends `labels: null` on the wire so the server inherits source labels. + await vm.fork("inherited") + fork_inherit_body = json.loads(state.requests[-1][2]) + assert fork_inherit_body["labels"] is None + assert state.sandbox_labels["forked-inherited"] == labels + + # Fork with explicit labels sends them; fork with `{}` sends `{}` to clear inherited labels. + await vm.fork("overridden", labels={"suite": "fork"}) + fork_override_body = json.loads(state.requests[-1][2]) + assert fork_override_body["labels"] == {"suite": "fork"} + assert state.sandbox_labels["forked-overridden"] == {"suite": "fork"} + + await vm.fork("cleared", labels={}) + fork_clear_body = json.loads(state.requests[-1][2]) + assert fork_clear_body["labels"] == {} + assert state.sandbox_labels["forked-cleared"] is None + + # Empty labels dict on create is sent as `{}` and normalized to `None` by the server. + empty_vm = await hv.create(labels={}) + empty_body = json.loads(state.requests[-1][2]) + assert empty_body["labels"] == {} + assert (await empty_vm.info()).labels is None + + state.names = ["vm-a", "vm-b"] + state.sandbox_labels = {"vm-0": {"suite": "eval"}, "vm-1": None} + listed = await hv.list() + assert [s.labels for s in listed.sandboxes] == [{"suite": "eval"}, None] + + asyncio.run(run()) + + +@pytest.mark.parametrize("bad_labels", ["not-a-dict", [("a", "b")], {1: "v"}, {"k": 2}]) +def test_invalid_label_types_are_rejected_by_pydantic_before_network(bad_labels: Any) -> None: + async def run() -> None: + async with Hypervisor("http://127.0.0.1:1", "token") as hv: + with pytest.raises(ValidationError): + await hv.create(labels=bad_labels) + async with VM("http://127.0.0.1:1", "token", id="vm-0") as vm: + with pytest.raises(ValidationError): + await vm.fork("forked", labels=bad_labels) + + asyncio.run(run()) diff --git a/sdk/rust/src/hypervisor.rs b/sdk/rust/src/hypervisor.rs index 2c479ff52..e499ff44a 100644 --- a/sdk/rust/src/hypervisor.rs +++ b/sdk/rust/src/hypervisor.rs @@ -108,6 +108,7 @@ impl Hypervisor { cpus: options.cpus, ram_mb, env, + labels: options.labels, from: None, networks: options.networks.into_iter().map(|network| network.name).collect(), container, diff --git a/sdk/rust/src/options.rs b/sdk/rust/src/options.rs index 199f41524..e561f1a02 100644 --- a/sdk/rust/src/options.rs +++ b/sdk/rust/src/options.rs @@ -49,6 +49,7 @@ pub struct CreateOptions { /// Guest memory in GiB. pub memory: Option, pub env: Option>, + pub labels: Option>, pub networks: Vec, pub image: Option, pub command: Vec, diff --git a/sdk/rust/src/tests.rs b/sdk/rust/src/tests.rs index b3dcf89cc..717b1bb2a 100644 --- a/sdk/rust/src/tests.rs +++ b/sdk/rust/src/tests.rs @@ -42,6 +42,7 @@ async fn hypervisor_defaults_overrides_update_and_vm_handle_lifetime() { cpus: Some(4), memory: Some(8), env: Some([("EDITOR".into(), "vim".into())].into()), + labels: Some([("suite".into(), "eval".into())].into()), networks: vec![network.clone()], ..Default::default() }) @@ -56,6 +57,7 @@ async fn hypervisor_defaults_overrides_update_and_vm_handle_lifetime() { "cpus": 4, "persistent": true, "env": {"EDITOR": "vim"}, + "labels": {"suite": "eval"}, "networks": [network.name], }) ); @@ -200,10 +202,17 @@ async fn controls_and_resources_use_the_canonical_vm_routes() { request(&mut server, "/vms/vm-1/stop").await; vm.delete().await.unwrap(); request(&mut server, "/vms/vm-1/delete").await; - let fork = vm.fork("branch", Some("notes".into())).await.unwrap(); + let fork = vm + .fork( + "branch", + Some("notes".into()), + Some([("suite".into(), "eval".into())].into()), + ) + .await + .unwrap(); assert_eq!( request(&mut server, "/vms/vm-1/fork").await, - json!({"name":"branch","description":"notes"}) + json!({"name":"branch","description":"notes","labels":{"suite":"eval"}}) ); drop(vm); fork.info().await.unwrap(); diff --git a/sdk/rust/src/vm.rs b/sdk/rust/src/vm.rs index a6abe9b0d..6fec0db95 100644 --- a/sdk/rust/src/vm.rs +++ b/sdk/rust/src/vm.rs @@ -221,12 +221,18 @@ impl VM { .await } - pub async fn fork(&self, name: &str, description: Option) -> Result { + pub async fn fork( + &self, + name: &str, + description: Option, + labels: Option>, + ) -> Result { let params = api::ForkVmParams { id: self.resolve().await?, body: models::ForkRequest { name: name.into(), description, + labels, }, }; let result = api::fork_vm(&self.client.transport, ¶ms, self.client.options).await?; diff --git a/sdk/specification/openapi.json b/sdk/specification/openapi.json index ea3b8fa3f..69e670e5f 100644 --- a/sdk/specification/openapi.json +++ b/sdk/specification/openapi.json @@ -4067,6 +4067,19 @@ "null" ] }, + "labels": { + "type": [ + "object", + "null" + ], + "description": "Key-value metadata labels for the forked sandbox. If absent, inherits the source's labels.", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, "name": { "type": "string" } @@ -5713,6 +5726,19 @@ ], "description": "Sandbox to clone state from. If provided, the new sandbox's session will\nbe cloned from this existing persistent sandbox." }, + "labels": { + "type": [ + "object", + "null" + ], + "description": "Key-value metadata labels attached to the sandbox at creation.", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, "name": { "type": [ "string", @@ -6069,6 +6095,19 @@ "id": { "type": "string" }, + "labels": { + "type": [ + "object", + "null" + ], + "description": "Key-value metadata labels attached when the sandbox was created.", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + }, "last_error": { "type": [ "string", diff --git a/sdk/typescript/src/hypervisor.ts b/sdk/typescript/src/hypervisor.ts index d572de00e..8530f30dc 100644 --- a/sdk/typescript/src/hypervisor.ts +++ b/sdk/typescript/src/hypervisor.ts @@ -67,6 +67,7 @@ export class Hypervisor extends Client { name: options.name || null, persistent: Boolean(options.name), cpus: options.cpus ?? null, ram_mb, env: container === undefined ? options.env ?? null : null, + ...(options.labels === undefined ? {} : {labels: options.labels}), networks: (options.networks ?? []).map(network => network.name), ...(container === undefined ? {} : {container}), }}, {...options, timeoutMs: options.timeoutMs ?? createDeadlineMs(this.transport.timeoutMs)}); diff --git a/sdk/typescript/src/models/ForkRequest.ts b/sdk/typescript/src/models/ForkRequest.ts index 7a258db0e..48c3a5d13 100644 --- a/sdk/typescript/src/models/ForkRequest.ts +++ b/sdk/typescript/src/models/ForkRequest.ts @@ -4,5 +4,6 @@ export interface ForkRequest { "description"?: string | null; + "labels"?: Record | null; "name": string; } diff --git a/sdk/typescript/src/models/ProvisionRequest.ts b/sdk/typescript/src/models/ProvisionRequest.ts index 620d66250..4112c4873 100644 --- a/sdk/typescript/src/models/ProvisionRequest.ts +++ b/sdk/typescript/src/models/ProvisionRequest.ts @@ -7,6 +7,7 @@ export interface ProvisionRequest { "cpus"?: number | null; "env"?: Record | null; "from"?: string | null; + "labels"?: Record | null; "name"?: string | null; "networks"?: Array; "persistent"?: boolean; diff --git a/sdk/typescript/src/models/SandboxInfo.ts b/sdk/typescript/src/models/SandboxInfo.ts index 0f2c91243..5a4690a8b 100644 --- a/sdk/typescript/src/models/SandboxInfo.ts +++ b/sdk/typescript/src/models/SandboxInfo.ts @@ -20,6 +20,7 @@ export interface SandboxInfo { "files"?: null | VmFilesInfo; "forked_from"?: string | null; "id": string; + "labels"?: Record | null; "last_error"?: string | null; "model_call_count"?: number | null; "name"?: string | null; diff --git a/sdk/typescript/src/options.ts b/sdk/typescript/src/options.ts index e13e87f2f..45a47269a 100644 --- a/sdk/typescript/src/options.ts +++ b/sdk/typescript/src/options.ts @@ -7,7 +7,7 @@ export interface ImageListOptions extends CallOptions {refresh?: boolean} export interface ImagePullOptions extends CallOptions {registry?: Registry} export interface CreateOptions extends CallOptions { name?: string; cpus?: number; memory?: number; - env?: Record; networks?: readonly NetworkInfo[]; + env?: Record; labels?: Record; networks?: readonly NetworkInfo[]; image?: string; command?: readonly string[]; registry?: Registry; } export interface RunOptions extends CallOptions { diff --git a/sdk/typescript/src/validation/ForkRequest.ts b/sdk/typescript/src/validation/ForkRequest.ts index 50038857a..51bdf6bc3 100644 --- a/sdk/typescript/src/validation/ForkRequest.ts +++ b/sdk/typescript/src/validation/ForkRequest.ts @@ -5,5 +5,6 @@ import type {ForkRequest} from "../models/ForkRequest.js"; export const ForkRequestSchema: z.ZodType = z.object({ "description": z.string().nullable().exactOptional(), + "labels": z.record(z.string(), z.string()).nullable().exactOptional(), "name": z.string(), }); diff --git a/sdk/typescript/src/validation/ProvisionRequest.ts b/sdk/typescript/src/validation/ProvisionRequest.ts index c7519d301..266cb1f93 100644 --- a/sdk/typescript/src/validation/ProvisionRequest.ts +++ b/sdk/typescript/src/validation/ProvisionRequest.ts @@ -9,6 +9,7 @@ export const ProvisionRequestSchema: z.ZodType = z.strictObjec "cpus": z.int().min(0).nullable().exactOptional(), "env": z.record(z.string(), z.string()).nullable().exactOptional(), "from": z.string().nullable().exactOptional(), + "labels": z.record(z.string(), z.string()).nullable().exactOptional(), "name": z.string().nullable().exactOptional(), "networks": z.array(z.string()).exactOptional(), "persistent": z.boolean().exactOptional(), diff --git a/sdk/typescript/src/validation/SandboxInfo.ts b/sdk/typescript/src/validation/SandboxInfo.ts index e02e22cd2..4a00d2025 100644 --- a/sdk/typescript/src/validation/SandboxInfo.ts +++ b/sdk/typescript/src/validation/SandboxInfo.ts @@ -22,6 +22,7 @@ export const SandboxInfoSchema: z.ZodType = z.object({ "files": z.union([z.null(), z.lazy(() => VmFilesInfoSchema)]).exactOptional(), "forked_from": z.string().nullable().exactOptional(), "id": z.string(), + "labels": z.record(z.string(), z.string()).nullable().exactOptional(), "last_error": z.string().nullable().exactOptional(), "model_call_count": z.int().min(0).nullable().exactOptional(), "name": z.string().nullable().exactOptional(), diff --git a/sdk/typescript/src/vm.ts b/sdk/typescript/src/vm.ts index 9b38e2ce8..7c01207d9 100644 --- a/sdk/typescript/src/vm.ts +++ b/sdk/typescript/src/vm.ts @@ -110,9 +110,12 @@ export class VM extends Client { const {transport, id} = await this.context(options); return api.deleteVm(transport, {id}, options); } - async fork(name: string, options: CallOptions & {description?: string} = {}): Promise { + async fork(name: string, options: CallOptions & {description?: string; labels?: Record} = {}): Promise { const {transport, id} = await this.context(options); - const response = await api.forkVm(transport, {id, body: {name, description: options.description ?? null}}, options); + const response = await api.forkVm(transport, {id, body: { + name, description: options.description ?? null, + ...(options.labels === undefined ? {} : {labels: options.labels}), + }}, options); return VM.bind(transport, response.id, response.name, this.#hasContainer); } async log(options: LogOptions = {}): Promise { diff --git a/sdk/typescript/tests/facade.test.ts b/sdk/typescript/tests/facade.test.ts index 17ff5566c..5629843a8 100644 --- a/sdk/typescript/tests/facade.test.ts +++ b/sdk/typescript/tests/facade.test.ts @@ -10,12 +10,12 @@ it('creates bound VM handles with service defaults and shared lifetime', async ( async (url, received) => { const hv = new Hypervisor(url, 'secret'); const network = {...sample(schemas.NetworkInfo ?? {}) as object, name: 'team'} as NetworkInfo; - const vm = await hv.create({name: 'chosen', memory: 8, cpus: 4, networks: [network]}); + const vm = await hv.create({name: 'chosen', memory: 8, cpus: 4, labels: {suite: 'eval'}, networks: [network]}); expect(vm).toBeInstanceOf(VM); expect(vm.id).toBe('vm-0'); expect(vm.name).toBe('chosen'); expect(JSON.parse(received[0]?.body.toString() ?? '')).toEqual({ - name: 'chosen', persistent: true, cpus: 4, ram_mb: 8192, env: null, networks: ['team'], + name: 'chosen', persistent: true, cpus: 4, ram_mb: 8192, env: null, labels: {suite: 'eval'}, networks: ['team'], }); vm.close(); await expect(vm.info()).rejects.toThrow('closed'); @@ -56,7 +56,7 @@ it('maps every facade method through HTTP and resolves a name once', async () => await vm.log(); await vm.files.list(); await vm.files.list('/nested', {depth: 2}); await vm.files.list('/root', {exact: true}); - const fork = await vm.fork('copy', {description: 'checkpoint'}); + const fork = await vm.fork('copy', {description: 'checkpoint', labels: {suite: 'fork'}}); expect(fork.id).toBe('fork-0'); expect(fork.name).toBe('copy'); fork.close(); const another = await vm.fork('another'); another.close(); @@ -76,6 +76,13 @@ it('maps every facade method through HTTP and resolves a name once', async () => expect(received.some(request => request.url === '/vms/vm-0/files/list')).toBe(true); expect(received.some(request => request.url === '/vms/vm-0/files/list?path=%2Froot&exact=true')).toBe(true); expect(received.some(request => request.url.includes('layers=fs%2Cexec'))).toBe(true); + const forks = received.filter(request => request.url === '/vms/vm-0/fork'); + expect(JSON.parse(forks[0]?.body.toString() ?? '')).toEqual({ + name: 'copy', description: 'checkpoint', labels: {suite: 'fork'}, + }); + expect(JSON.parse(forks[1]?.body.toString() ?? '')).toEqual({ + name: 'another', description: null, + }); await hv.info(); await hv.list(); await hv.log(); await hv.log({source: HostLogSource.GATEWAY, tail: 2}); await hv.run('printf ok', {timeout_secs: 4}); @@ -145,9 +152,9 @@ it.each([1, 8])('accepts positive memory in GiB: %s', async memory => { await gateway((request, response) => state.handle(request, response), async (url, received) => { const hv = new Hypervisor(url, 'secret'); try { - const vm = await hv.create({memory, env: {LANG: 'C'}}); + const vm = await hv.create({memory, env: {LANG: 'C'}, labels: {}}); const created = received.find(entry => entry.url === '/vms/create'); - expect(JSON.parse(created?.body.toString() ?? '')).toMatchObject({ram_mb: memory * 1024, env: {LANG: 'C'}}); + expect(JSON.parse(created?.body.toString() ?? '')).toMatchObject({ram_mb: memory * 1024, env: {LANG: 'C'}, labels: {}}); vm.close(); } finally {hv.close();} });