Skip to main content

opencode_cloud_core/docker/
profile.rs

1//! Docker resource naming for optional sandbox profile isolation.
2//!
3//! Legacy behavior uses shared resource names (`opencode-cloud-sandbox`, `latest`, etc.).
4//! When `OPENCODE_SANDBOX_INSTANCE` is set to a valid instance ID, names are derived with
5//! profile-specific suffixes/tags so concurrent worktrees can run independently.
6
7use super::container::CONTAINER_NAME;
8use super::dockerfile::IMAGE_TAG_DEFAULT;
9use super::volume::{
10    VOLUME_CACHE, VOLUME_CONFIG, VOLUME_PROJECTS, VOLUME_SESSION, VOLUME_SSH, VOLUME_STATE,
11    VOLUME_USERS,
12};
13use std::env;
14
15/// Environment variable carrying the active sandbox instance id.
16pub const SANDBOX_INSTANCE_ENV: &str = "OPENCODE_SANDBOX_INSTANCE";
17
18/// Container and volume label key identifying the active instance.
19pub const INSTANCE_LABEL_KEY: &str = "opencode-cloud.instance";
20
21/// Legacy rollback tag for shared mode.
22const PREVIOUS_TAG_DEFAULT: &str = "previous";
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct DockerResourceNames {
26    pub instance_id: Option<String>,
27    pub suffix: Option<String>,
28    pub container_name: String,
29    pub hostname: String,
30    pub image_tag: String,
31    pub previous_image_tag: String,
32    pub volume_session: String,
33    pub volume_state: String,
34    pub volume_cache: String,
35    pub volume_projects: String,
36    pub volume_config: String,
37    pub volume_users: String,
38    pub volume_ssh: String,
39    pub image_state_file: String,
40}
41
42impl DockerResourceNames {
43    pub fn volume_names(&self) -> [&str; 7] {
44        [
45            &self.volume_session,
46            &self.volume_state,
47            &self.volume_cache,
48            &self.volume_projects,
49            &self.volume_config,
50            &self.volume_users,
51            &self.volume_ssh,
52        ]
53    }
54}
55
56/// Resolve active resource names from `OPENCODE_SANDBOX_INSTANCE`.
57pub fn active_resource_names() -> DockerResourceNames {
58    resource_names_for_instance(env_instance_id().as_deref())
59}
60
61/// Resolve resource names for an optional instance id.
62pub fn resource_names_for_instance(instance_id: Option<&str>) -> DockerResourceNames {
63    if let Some(instance_id) = instance_id {
64        let suffix = format!("-{instance_id}");
65        // Keep default names untouched for backward compatibility; profile mode only appends.
66        DockerResourceNames {
67            instance_id: Some(instance_id.to_string()),
68            suffix: Some(suffix.clone()),
69            container_name: format!("{CONTAINER_NAME}{suffix}"),
70            hostname: format!("{CONTAINER_NAME}{suffix}"),
71            image_tag: format!("instance-{instance_id}"),
72            previous_image_tag: format!("instance-{instance_id}-previous"),
73            volume_session: format!("{VOLUME_SESSION}{suffix}"),
74            volume_state: format!("{VOLUME_STATE}{suffix}"),
75            volume_cache: format!("{VOLUME_CACHE}{suffix}"),
76            volume_projects: format!("{VOLUME_PROJECTS}{suffix}"),
77            volume_config: format!("{VOLUME_CONFIG}{suffix}"),
78            volume_users: format!("{VOLUME_USERS}{suffix}"),
79            volume_ssh: format!("{VOLUME_SSH}{suffix}"),
80            image_state_file: format!("image-state-{instance_id}.json"),
81        }
82    } else {
83        DockerResourceNames {
84            instance_id: None,
85            suffix: None,
86            container_name: CONTAINER_NAME.to_string(),
87            hostname: CONTAINER_NAME.to_string(),
88            image_tag: IMAGE_TAG_DEFAULT.to_string(),
89            previous_image_tag: PREVIOUS_TAG_DEFAULT.to_string(),
90            volume_session: VOLUME_SESSION.to_string(),
91            volume_state: VOLUME_STATE.to_string(),
92            volume_cache: VOLUME_CACHE.to_string(),
93            volume_projects: VOLUME_PROJECTS.to_string(),
94            volume_config: VOLUME_CONFIG.to_string(),
95            volume_users: VOLUME_USERS.to_string(),
96            volume_ssh: VOLUME_SSH.to_string(),
97            image_state_file: "image-state.json".to_string(),
98        }
99    }
100}
101
102/// Remap legacy container name to active profile container name.
103pub fn remap_container_name(name: &str) -> String {
104    if name == CONTAINER_NAME {
105        return active_resource_names().container_name;
106    }
107    name.to_string()
108}
109
110/// Remap legacy image tags to active profile tags.
111pub fn remap_image_tag(tag: &str) -> String {
112    let names = active_resource_names();
113    if tag == IMAGE_TAG_DEFAULT {
114        return names.image_tag;
115    }
116    if tag == PREVIOUS_TAG_DEFAULT {
117        return names.previous_image_tag;
118    }
119    tag.to_string()
120}
121
122/// Read and validate sandbox instance from environment.
123pub fn env_instance_id() -> Option<String> {
124    let raw = env::var(SANDBOX_INSTANCE_ENV).ok()?;
125    let trimmed = raw.trim().to_ascii_lowercase();
126    if is_valid_instance_id(&trimmed) {
127        Some(trimmed)
128    } else {
129        None
130    }
131}
132
133fn is_valid_instance_id(value: &str) -> bool {
134    let bytes = value.as_bytes();
135    if bytes.is_empty() || bytes.len() > 32 {
136        return false;
137    }
138    if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() {
139        return false;
140    }
141    bytes
142        .iter()
143        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn legacy_names_remain_unchanged() {
152        let names = resource_names_for_instance(None);
153        assert_eq!(names.container_name, CONTAINER_NAME);
154        assert_eq!(names.image_tag, IMAGE_TAG_DEFAULT);
155        assert_eq!(names.volume_users, VOLUME_USERS);
156        assert_eq!(names.image_state_file, "image-state.json");
157        assert!(names.instance_id.is_none());
158    }
159
160    #[test]
161    fn isolated_names_include_suffixes() {
162        let names = resource_names_for_instance(Some("foo"));
163        assert_eq!(names.container_name, "opencode-cloud-sandbox-foo");
164        assert_eq!(names.image_tag, "instance-foo");
165        assert_eq!(names.previous_image_tag, "instance-foo-previous");
166        assert_eq!(names.volume_users, "opencode-users-foo");
167        assert_eq!(names.volume_ssh, "opencode-ssh-foo");
168        assert_eq!(names.image_state_file, "image-state-foo.json");
169        assert_eq!(names.instance_id.as_deref(), Some("foo"));
170    }
171
172    #[test]
173    fn env_instance_id_rejects_invalid_values() {
174        assert!(is_valid_instance_id("foo-123"));
175        assert!(!is_valid_instance_id(""));
176        assert!(!is_valid_instance_id("-foo"));
177        assert!(!is_valid_instance_id("foo_bar"));
178        assert!(!is_valid_instance_id("Foo"));
179    }
180}