diff --git a/docs/public/integrations/daytona.mdx b/docs/public/integrations/daytona.mdx index e62722b51..b1120febb 100644 --- a/docs/public/integrations/daytona.mdx +++ b/docs/public/integrations/daytona.mdx @@ -50,6 +50,14 @@ project = "fabro" env = "staging" team = "platform" + +Fabro also adds reserved labels to every managed Daytona sandbox: +`sh.fabro.managed=true` and, when available, `sh.fabro.run_id=`. +These match Docker sandbox labels and are useful for filtering provider resources +back to Fabro-managed runs. User-provided values for these reserved keys are +overwritten. + + [run.sandbox.daytona.snapshot] name = "rust-dev" cpu = 4 diff --git a/docs/superpowers/plans/2026-05-20-daytona-managed-sandbox-labels.md b/docs/superpowers/plans/2026-05-20-daytona-managed-sandbox-labels.md new file mode 100644 index 000000000..8d02ddc82 --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-daytona-managed-sandbox-labels.md @@ -0,0 +1,363 @@ +# Daytona Managed Sandbox Labels Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Automatically label Daytona sandboxes as Fabro-managed resources, matching Docker's existing labels. + +**Architecture:** Move the existing Docker managed-label constants into a shared `fabro-sandbox` helper, reuse that helper from Docker and Daytona, and merge provider-native user labels with Fabro reserved labels at sandbox creation time. Daytona Snapshots stay unchanged because the Daytona snapshot create/list API does not expose labels. + +**Tech Stack:** Rust, `fabro-sandbox`, Daytona Rust SDK `SandboxBaseParams.labels`, Bollard Docker container labels, public docs in `docs/public/integrations/daytona.mdx`. + +--- + +## Summary + +- Add these labels to every Fabro-created Daytona sandbox: + + ```text + sh.fabro.managed=true + sh.fabro.run_id= + ``` + +- Match Docker's current label names exactly. +- Use only conservative ASCII label-key characters: lowercase letters, digits, dots, and underscore. The chosen keys are `sh.fabro.managed` and `sh.fabro.run_id`. +- Preserve user-configured `[run.sandbox.daytona.labels]`, but make Fabro's reserved labels authoritative on collisions. +- Do not add labels to Daytona snapshots. + +## Task 1: Create Shared Managed Label Helper + +**Files:** +- Create: `lib/crates/fabro-sandbox/src/managed_labels.rs` +- Modify: `lib/crates/fabro-sandbox/src/lib.rs` +- Modify: `lib/crates/fabro-sandbox/src/docker.rs` + +- [x] **Step 1: Add shared helper module** + + Create `lib/crates/fabro-sandbox/src/managed_labels.rs`: + + ```rust + use std::collections::HashMap; + + use fabro_types::RunId; + + pub(crate) const MANAGED_LABEL: &str = "sh.fabro.managed"; + pub(crate) const RUN_ID_LABEL: &str = "sh.fabro.run_id"; + + #[cfg(any(feature = "docker", test))] + pub(crate) fn for_run(run_id: Option<&RunId>) -> HashMap { + let mut labels = HashMap::new(); + insert_for_run(&mut labels, run_id); + labels + } + + #[cfg(any(feature = "daytona", test))] + pub(crate) fn merge_for_run( + user_labels: Option<&HashMap>, + run_id: Option<&RunId>, + ) -> HashMap { + let mut labels = user_labels.cloned().unwrap_or_default(); + insert_for_run(&mut labels, run_id); + labels + } + + fn insert_for_run(labels: &mut HashMap, run_id: Option<&RunId>) { + labels.insert(MANAGED_LABEL.to_string(), "true".to_string()); + if let Some(run_id) = run_id { + labels.insert(RUN_ID_LABEL.to_string(), run_id.to_string()); + } + } + + #[cfg(test)] + mod tests { + use super::*; + + fn conservative_daytona_key(key: &str) -> bool { + key.chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '.' | '_')) + } + + #[test] + fn managed_label_keys_match_docker_and_use_conservative_ascii() { + assert_eq!(MANAGED_LABEL, "sh.fabro.managed"); + assert_eq!(RUN_ID_LABEL, "sh.fabro.run_id"); + assert!(conservative_daytona_key(MANAGED_LABEL)); + assert!(conservative_daytona_key(RUN_ID_LABEL)); + } + + #[test] + fn managed_labels_include_run_id_when_present() { + let run_id: RunId = "01HY0000000000000000000000".parse().unwrap(); + let labels = for_run(Some(&run_id)); + + assert_eq!(labels.get(MANAGED_LABEL).map(String::as_str), Some("true")); + assert_eq!( + labels.get(RUN_ID_LABEL).map(String::as_str), + Some("01HY0000000000000000000000") + ); + } + + #[test] + fn managed_labels_override_reserved_user_labels() { + let run_id: RunId = "01HY0000000000000000000000".parse().unwrap(); + let user_labels = HashMap::from([ + ("team".to_string(), "platform".to_string()), + (MANAGED_LABEL.to_string(), "false".to_string()), + (RUN_ID_LABEL.to_string(), "wrong".to_string()), + ]); + + let labels = merge_for_run(Some(&user_labels), Some(&run_id)); + + assert_eq!(labels.get("team").map(String::as_str), Some("platform")); + assert_eq!(labels.get(MANAGED_LABEL).map(String::as_str), Some("true")); + assert_eq!( + labels.get(RUN_ID_LABEL).map(String::as_str), + Some("01HY0000000000000000000000") + ); + } + } + ``` + +- [x] **Step 2: Register the module** + + Add a crate-private module declaration in `lib/crates/fabro-sandbox/src/lib.rs`: + + ```rust + mod managed_labels; + ``` + +- [x] **Step 3: Reuse helper from Docker without changing behavior** + + In `lib/crates/fabro-sandbox/src/docker.rs`: + + - Remove the local `MANAGED_LABEL`, `RUN_ID_LABEL`, and `container_labels()` definitions. + - Import the helper: + + ```rust + use crate::managed_labels::{self, MANAGED_LABEL, RUN_ID_LABEL}; + ``` + + - Change `container_config()` to keep the same output: + + ```rust + labels: Some(managed_labels::for_run(run_id)), + ``` + + - Leave `verify_managed_labels()` behavior and error text unchanged except for using the imported constants. + +- [x] **Step 4: Run focused Docker label test** + + ```bash + cargo test -p fabro-sandbox docker::tests::real_run_container_gets_name_and_labels --no-default-features --features docker + ``` + + Expected: test passes and still asserts the exact Docker labels. + +## Task 2: Add Managed Labels to Daytona Create Params + +**Files:** +- Modify: `lib/crates/fabro-sandbox/src/daytona/mod.rs` + +- [x] **Step 1: Import shared helper** + + Add: + + ```rust + use crate::managed_labels; + ``` + +- [x] **Step 2: Merge user and managed labels in `base_params()`** + + In `DaytonaSandbox::base_params()`, replace: + + ```rust + labels: self.config.labels.clone(), + ``` + + with: + + ```rust + labels: Some(managed_labels::merge_for_run( + self.config.labels.as_ref(), + self.run_id.as_ref(), + )), + ``` + + This means a default Daytona sandbox now sends `{"sh.fabro.managed": "true"}` instead of omitting labels. + +- [x] **Step 3: Add Daytona unit tests** + + Add tests near `base_params_create_run_owned_non_ephemeral_sandbox()`: + + ```rust + #[tokio::test] + async fn base_params_merges_managed_daytona_labels() { + let run_id: RunId = "01HY0000000000000000000000".parse().unwrap(); + let sandbox = DaytonaSandbox::new( + DaytonaConfig { + labels: Some(HashMap::from([ + ("team".to_string(), "platform".to_string()), + (managed_labels::MANAGED_LABEL.to_string(), "false".to_string()), + (managed_labels::RUN_ID_LABEL.to_string(), "wrong".to_string()), + ])), + ..Default::default() + }, + None, + Some(run_id), + None, + None, + Some("dtn_test".to_string()), + ) + .await + .expect("sandbox config should be valid"); + + assert_eq!( + sandbox.base_params().labels, + Some(HashMap::from([ + ("team".to_string(), "platform".to_string()), + (managed_labels::MANAGED_LABEL.to_string(), "true".to_string()), + ( + managed_labels::RUN_ID_LABEL.to_string(), + "01HY0000000000000000000000".to_string(), + ), + ])) + ); + } + ``` + + If `HashMap` or `RunId` are not already imported in the test module, add test-only imports inside the existing `#[cfg(test)] mod tests`. + +- [x] **Step 4: Run focused Daytona tests** + + ```bash + cargo test -p fabro-sandbox daytona::tests::base_params --no-default-features --features daytona + ``` + + Expected: existing base params test and new Daytona label tests pass. + +## Task 3: Add Live Daytona Validation + +**Files:** +- Modify: `lib/crates/fabro-sandbox/tests/daytona_streaming_live.rs` + +- [x] **Step 1: Add ignored live test** + + Add a live smoke test under the existing `#[cfg(feature = "daytona")]` module: + + ```rust + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[ignore = "requires live Daytona credentials and provisions a sandbox"] + async fn daytona_managed_labels_live_smoke() -> Result<()> { + ensure!( + daytona_api_key_present(), + "DAYTONA_API_KEY must be set to run this live smoke test" + ); + + let run_id: fabro_types::RunId = "01HY0000000000000000000000".parse().unwrap(); + let sandbox = DaytonaSandbox::new( + DaytonaConfig { + skip_clone: true, + labels: Some(std::collections::HashMap::from([( + "team".to_string(), + "platform".to_string(), + )])), + ..Default::default() + }, + None, + Some(run_id), + None, + None, + None, + ) + .await?; + + sandbox.initialize().await?; + let labels = sandbox + .sandbox_handle() + .context("sandbox handle should be initialized")? + .labels + .clone(); + let cleanup_result = sandbox.cleanup().await.context("clean up Daytona sandbox"); + + ensure_eq( + labels.get("sh.fabro.managed").map(String::as_str), + Some("true"), + "Daytona should accept and return the managed label", + )?; + ensure_eq( + labels.get("sh.fabro.run_id").map(String::as_str), + Some("01HY0000000000000000000000"), + "Daytona should accept and return the run id label", + )?; + ensure_eq( + labels.get("team").map(String::as_str), + Some("platform"), + "Daytona should preserve user labels", + )?; + cleanup_result?; + + Ok(()) + } + ``` + +- [x] **Step 2: Keep the live test ignored** + + Do not make this test part of normal unit test execution. It provisions a real Daytona sandbox and should only run under the existing ignored/live workflow. + +## Task 4: Document Reserved Daytona Labels + +**Files:** +- Modify: `docs/public/integrations/daytona.mdx` + +- [x] **Step 1: Add a short note under the labels example** + + After the `[run.sandbox.daytona.labels]` example, add: + + ```mdx + + Fabro also adds reserved labels to every managed Daytona sandbox: + `sh.fabro.managed=true` and, when available, `sh.fabro.run_id=`. + These match Docker sandbox labels and are useful for filtering provider resources + back to Fabro-managed runs. User-provided values for these reserved keys are + overwritten. + + ``` + +- [x] **Step 2: Keep snapshot docs unchanged** + + Do not document snapshot labels. Daytona snapshot create params currently have no label field, and Fabro does not add one. + +## Test Plan + +- [x] Run focused managed-label tests: + + ```bash + cargo test -p fabro-sandbox managed_labels --no-default-features --features docker,daytona + cargo test -p fabro-sandbox docker::tests::real_run_container_gets_name_and_labels --no-default-features --features docker + cargo test -p fabro-sandbox daytona::tests::base_params --no-default-features --features daytona + ``` + +- [x] Run the full sandbox crate tests for both providers: + + ```bash + cargo test -p fabro-sandbox --no-default-features --features docker,daytona + ``` + +- [x] Run formatting and linting: + + ```bash + cargo +nightly-2026-04-14 fmt --check --all + cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings + ``` + +- [ ] Optional live Daytona validation: + + ```bash + set -a && source .env && set +a && cargo nextest run -p fabro-sandbox --profile e2e --run-ignored only daytona_managed_labels_live_smoke + ``` + +## Assumptions + +- Daytona accepts label keys containing dots and underscores. This is supported by Daytona's current API shape (`Record`/JSON object labels), and the live ignored test validates it against the real service. +- Fabro reserved labels should be authoritative so cleanup, filtering, and tracing cannot be broken by user config collisions. +- Docker behavior must remain byte-for-byte equivalent for the existing label keys. +- Daytona snapshot labels are out of scope because the SDK/API does not expose labels for snapshots. diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs index cd6f37bfb..f91c0a7c0 100644 --- a/lib/crates/fabro-sandbox/src/daytona/mod.rs +++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs @@ -29,7 +29,8 @@ use crate::redact::redact_auth_url; use crate::sandbox::{optional_timeout, resolve_path}; use crate::{ CommandOutputCallback, DirEntry, ExecResult, ExecStreamingResult, GrepOptions, Sandbox, - SandboxEvent, SandboxEventCallback, StdioProcess, format_lines_numbered, shell_quote, + SandboxEvent, SandboxEventCallback, StdioProcess, format_lines_numbered, managed_labels, + shell_quote, }; pub(crate) const WORKING_DIRECTORY: &str = "/home/daytona/workspace"; @@ -459,7 +460,10 @@ impl DaytonaSandbox { daytona_sdk::SandboxBaseParams { name: Some(name), auto_stop_interval: self.config.auto_stop_interval, - labels: self.config.labels.clone(), + labels: Some(managed_labels::merge_for_run( + self.config.labels.as_ref(), + self.run_id.as_ref(), + )), auto_delete_interval: Some(-1), ephemeral: Some(false), network_block_all, @@ -2269,6 +2273,56 @@ subpath = "agents" assert_eq!(params.ephemeral, Some(false)); assert_eq!(params.auto_delete_interval, Some(-1)); + assert_eq!( + params.labels, + Some(HashMap::from([( + managed_labels::MANAGED_LABEL.to_string(), + "true".to_string(), + )])) + ); + } + + #[tokio::test] + async fn base_params_merges_managed_daytona_labels() { + let run_id: RunId = "01HY0000000000000000000000".parse().unwrap(); + let sandbox = DaytonaSandbox::new( + DaytonaConfig { + labels: Some(HashMap::from([ + ("team".to_string(), "platform".to_string()), + ( + managed_labels::MANAGED_LABEL.to_string(), + "false".to_string(), + ), + ( + managed_labels::RUN_ID_LABEL.to_string(), + "wrong".to_string(), + ), + ])), + ..Default::default() + }, + None, + Some(run_id), + None, + None, + Some("dtn_test".to_string()), + ) + .await + .expect("sandbox config should be valid"); + + assert_eq!( + sandbox.base_params().labels, + Some(HashMap::from([ + ("team".to_string(), "platform".to_string()), + ( + managed_labels::MANAGED_LABEL.to_string(), + "true".to_string() + ), + ( + managed_labels::RUN_ID_LABEL.to_string(), + "01HY0000000000000000000000".to_string(), + ), + ])) + ); } #[test] diff --git a/lib/crates/fabro-sandbox/src/docker.rs b/lib/crates/fabro-sandbox/src/docker.rs index 23a95a264..2df8e039d 100644 --- a/lib/crates/fabro-sandbox/src/docker.rs +++ b/lib/crates/fabro-sandbox/src/docker.rs @@ -26,6 +26,7 @@ use tokio::{fs, time}; use tokio_util::sync::CancellationToken; use crate::clone_source::{self, CloneDecision, EmptyWorkspaceReason}; +use crate::managed_labels::{self, MANAGED_LABEL, RUN_ID_LABEL}; use crate::redact::redact_auth_url; use crate::sandbox::{StdioProcessControl, optional_timeout, resolve_path}; use crate::{ @@ -46,8 +47,6 @@ const EXEC_TERM_GRACE_SECONDS: &str = "0.02"; #[cfg(not(test))] const EXEC_TERM_GRACE_SECONDS: &str = "0.2"; -const MANAGED_LABEL: &str = "sh.fabro.managed"; -const RUN_ID_LABEL: &str = "sh.fabro.run_id"; static EXEC_CONTROL_COUNTER: AtomicU64 = AtomicU64::new(1); pub fn docker_access_command(container_id: &str, working_directory: &str) -> String { @@ -1060,14 +1059,6 @@ fn git_clone_and_link_command( ) } -fn container_labels(run_id: Option<&RunId>) -> HashMap { - let mut labels = HashMap::from([(MANAGED_LABEL.to_string(), "true".to_string())]); - if let Some(run_id) = run_id { - labels.insert(RUN_ID_LABEL.to_string(), run_id.to_string()); - } - labels -} - fn host_config(config: &DockerSandboxOptions) -> HostConfig { HostConfig { binds: None, @@ -1095,7 +1086,7 @@ fn container_config(config: &DockerSandboxOptions, run_id: Option<&RunId>) -> Co } else { Some(config.env_vars.clone()) }, - labels: Some(container_labels(run_id)), + labels: Some(managed_labels::for_run(run_id)), host_config: Some(host_config(config)), ..Default::default() } @@ -2079,7 +2070,7 @@ mod tests { container_name(&run_id), "fabro-run-01HY0000000000000000000000" ); - let labels = container_labels(Some(&run_id)); + let labels = managed_labels::for_run(Some(&run_id)); assert_eq!(labels.get(MANAGED_LABEL).map(String::as_str), Some("true")); assert_eq!( labels.get(RUN_ID_LABEL).map(String::as_str), diff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/crates/fabro-sandbox/src/lib.rs index 04ca2fb86..036a0dd1c 100644 --- a/lib/crates/fabro-sandbox/src/lib.rs +++ b/lib/crates/fabro-sandbox/src/lib.rs @@ -6,6 +6,9 @@ pub mod sandbox_spec; #[cfg(any(feature = "docker", feature = "daytona"))] mod clone_source; +#[cfg(any(feature = "docker", feature = "daytona", test))] +mod managed_labels; + pub mod read_guard; #[cfg(any(feature = "docker", feature = "daytona", test))] diff --git a/lib/crates/fabro-sandbox/src/managed_labels.rs b/lib/crates/fabro-sandbox/src/managed_labels.rs new file mode 100644 index 000000000..2841105bc --- /dev/null +++ b/lib/crates/fabro-sandbox/src/managed_labels.rs @@ -0,0 +1,83 @@ +use std::collections::HashMap; + +use fabro_types::RunId; + +pub(crate) const MANAGED_LABEL: &str = "sh.fabro.managed"; +pub(crate) const RUN_ID_LABEL: &str = "sh.fabro.run_id"; + +#[cfg(any(feature = "docker", test))] +pub(crate) fn for_run(run_id: Option<&RunId>) -> HashMap { + let mut labels = HashMap::new(); + insert_for_run(&mut labels, run_id); + labels +} + +#[cfg(any(feature = "daytona", test))] +pub(crate) fn merge_for_run( + user_labels: Option<&HashMap>, + run_id: Option<&RunId>, +) -> HashMap { + let mut labels = user_labels.cloned().unwrap_or_default(); + insert_for_run(&mut labels, run_id); + labels +} + +fn insert_for_run(labels: &mut HashMap, run_id: Option<&RunId>) { + labels.insert(MANAGED_LABEL.to_string(), "true".to_string()); + if let Some(run_id) = run_id { + labels.insert(RUN_ID_LABEL.to_string(), run_id.to_string()); + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use fabro_types::RunId; + + use super::*; + + fn conservative_daytona_key(key: &str) -> bool { + key.chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '.' | '_')) + } + + #[test] + fn managed_label_keys_match_docker_and_use_conservative_ascii() { + assert_eq!(MANAGED_LABEL, "sh.fabro.managed"); + assert_eq!(RUN_ID_LABEL, "sh.fabro.run_id"); + assert!(conservative_daytona_key(MANAGED_LABEL)); + assert!(conservative_daytona_key(RUN_ID_LABEL)); + } + + #[test] + fn managed_labels_include_run_id_when_present() { + let run_id: RunId = "01HY0000000000000000000000".parse().unwrap(); + let labels = for_run(Some(&run_id)); + + assert_eq!(labels.get(MANAGED_LABEL).map(String::as_str), Some("true")); + assert_eq!( + labels.get(RUN_ID_LABEL).map(String::as_str), + Some("01HY0000000000000000000000") + ); + } + + #[test] + fn managed_labels_override_reserved_user_labels() { + let run_id: RunId = "01HY0000000000000000000000".parse().unwrap(); + let user_labels = HashMap::from([ + ("team".to_string(), "platform".to_string()), + (MANAGED_LABEL.to_string(), "false".to_string()), + (RUN_ID_LABEL.to_string(), "wrong".to_string()), + ]); + + let labels = merge_for_run(Some(&user_labels), Some(&run_id)); + + assert_eq!(labels.get("team").map(String::as_str), Some("platform")); + assert_eq!(labels.get(MANAGED_LABEL).map(String::as_str), Some("true")); + assert_eq!( + labels.get(RUN_ID_LABEL).map(String::as_str), + Some("01HY0000000000000000000000") + ); + } +} diff --git a/lib/crates/fabro-sandbox/tests/daytona_streaming_live.rs b/lib/crates/fabro-sandbox/tests/daytona_streaming_live.rs index 51e982ccd..ad84e9335 100644 --- a/lib/crates/fabro-sandbox/tests/daytona_streaming_live.rs +++ b/lib/crates/fabro-sandbox/tests/daytona_streaming_live.rs @@ -52,6 +52,60 @@ mod daytona_streaming_live { Ok(()) } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[ignore = "requires live Daytona credentials and provisions a sandbox"] + async fn daytona_managed_labels_live_smoke() -> Result<()> { + ensure!( + daytona_api_key_present(), + "DAYTONA_API_KEY must be set to run this live smoke test" + ); + + let run_id: fabro_types::RunId = "01HY0000000000000000000000".parse().unwrap(); + let sandbox = DaytonaSandbox::new( + DaytonaConfig { + skip_clone: true, + labels: Some(std::collections::HashMap::from([( + "team".to_string(), + "platform".to_string(), + )])), + ..Default::default() + }, + None, + Some(run_id), + None, + None, + None, + ) + .await?; + + sandbox.initialize().await?; + let labels = sandbox + .sandbox_handle() + .context("sandbox handle should be initialized")? + .labels + .clone(); + let cleanup_result = sandbox.cleanup().await.context("clean up Daytona sandbox"); + + ensure_eq( + &labels.get("sh.fabro.managed").map(String::as_str), + &Some("true"), + "Daytona should accept and return the managed label", + )?; + ensure_eq( + &labels.get("sh.fabro.run_id").map(String::as_str), + &Some("01HY0000000000000000000000"), + "Daytona should accept and return the run id label", + )?; + ensure_eq( + &labels.get("team").map(String::as_str), + &Some("platform"), + "Daytona should preserve user labels", + )?; + cleanup_result?; + + Ok(()) + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires live Daytona credentials and provisions a sandbox"] async fn daytona_clone_layout_live_smoke() -> Result<()> {