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