Skip to main content

st/collab/
space.rs

1//! User Spaces - Containerized environments for collaborators
2//!
3//! Each collaborator gets their own space with:
4//! - Isolated or shared filesystem view
5//! - Their preferred tools (template)
6//! - Ability to share terminals, memories
7
8use super::{Identity, Template};
9use anyhow::Result;
10use serde::{Deserialize, Serialize};
11use std::path::PathBuf;
12
13/// Container isolation level
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
15pub enum IsolationLevel {
16    /// Full container (podman) - own filesystem, network
17    Podman,
18    /// Linux namespace - lighter, shared kernel
19    Namespace,
20    /// No isolation - direct access (trust mode)
21    #[default]
22    None,
23}
24
25/// Configuration for a user space
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SpaceConfig {
28    /// Isolation level
29    pub isolation: IsolationLevel,
30
31    /// Base template to use
32    pub template: Option<String>,
33
34    /// Working directory within the space
35    pub workdir: PathBuf,
36
37    /// Environment variables to set
38    pub env: Vec<(String, String)>,
39
40    /// Paths to mount into the space (host:container)
41    pub mounts: Vec<(PathBuf, PathBuf)>,
42
43    /// Memory limit (bytes, 0 = unlimited)
44    pub memory_limit: u64,
45
46    /// CPU limit (cores, 0 = unlimited)
47    pub cpu_limit: f32,
48
49    /// Network access
50    pub network: NetworkConfig,
51}
52
53impl Default for SpaceConfig {
54    fn default() -> Self {
55        SpaceConfig {
56            isolation: IsolationLevel::None,
57            template: None,
58            workdir: PathBuf::from("."),
59            env: Vec::new(),
60            mounts: Vec::new(),
61            memory_limit: 0,
62            cpu_limit: 0.0,
63            network: NetworkConfig::default(),
64        }
65    }
66}
67
68/// Network configuration for a space
69#[derive(Debug, Clone, Serialize, Deserialize, Default)]
70pub struct NetworkConfig {
71    /// Allow outbound internet access
72    pub internet: bool,
73    /// Allow connections to host services
74    pub host_access: bool,
75    /// Ports to expose
76    pub exposed_ports: Vec<u16>,
77}
78
79/// A user's active space in a project
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct UserSpace {
82    /// The user's identity
83    pub identity: Identity,
84
85    /// Space configuration
86    pub config: SpaceConfig,
87
88    /// Container/namespace ID (if isolated)
89    pub container_id: Option<String>,
90
91    /// Unix socket path for this space
92    pub socket_path: Option<PathBuf>,
93
94    /// PID of the space's shell process
95    pub shell_pid: Option<u32>,
96
97    /// When the space was created
98    pub created_at: u64,
99
100    /// Last activity timestamp
101    pub last_active: u64,
102}
103
104impl UserSpace {
105    /// Create a new user space
106    pub fn new(identity: Identity, config: SpaceConfig) -> Self {
107        let now = std::time::SystemTime::now()
108            .duration_since(std::time::UNIX_EPOCH)
109            .unwrap_or_default()
110            .as_secs();
111
112        UserSpace {
113            identity,
114            config,
115            container_id: None,
116            socket_path: None,
117            shell_pid: None,
118            created_at: now,
119            last_active: now,
120        }
121    }
122
123    /// Create a space with a template
124    pub fn with_template(identity: Identity, template: &Template) -> Self {
125        let config = SpaceConfig {
126            template: Some(template.name.clone()),
127            isolation: template.default_isolation.clone(),
128            env: template.env.clone(),
129            ..Default::default()
130        };
131        Self::new(identity, config)
132    }
133
134    /// Start the user space (create container if needed)
135    pub async fn start(&mut self) -> Result<()> {
136        match self.config.isolation {
137            IsolationLevel::Podman => self.start_podman().await,
138            IsolationLevel::Namespace => self.start_namespace().await,
139            IsolationLevel::None => self.start_direct().await,
140        }
141    }
142
143    /// Start with podman container
144    async fn start_podman(&mut self) -> Result<()> {
145        // TODO: Implement podman container creation
146        // podman run -d --name {identity} -v {project}:/workspace {template_image}
147        tracing::info!("Starting podman container for {}", self.identity);
148        Ok(())
149    }
150
151    /// Start with Linux namespace
152    async fn start_namespace(&mut self) -> Result<()> {
153        // TODO: Implement namespace isolation
154        // unshare --user --mount --pid --fork
155        tracing::info!("Starting namespace for {}", self.identity);
156        Ok(())
157    }
158
159    /// Start without isolation (trust mode)
160    async fn start_direct(&mut self) -> Result<()> {
161        tracing::info!("Starting direct space for {}", self.identity);
162        // Just set up the working directory and environment
163        Ok(())
164    }
165
166    /// Stop the user space
167    pub async fn stop(&mut self) -> Result<()> {
168        match self.config.isolation {
169            IsolationLevel::Podman => {
170                if let Some(ref id) = self.container_id {
171                    // podman stop {id}
172                    tracing::info!("Stopping podman container {}", id);
173                }
174            }
175            IsolationLevel::Namespace => {
176                if let Some(pid) = self.shell_pid {
177                    // kill namespace process
178                    tracing::info!("Stopping namespace pid {}", pid);
179                }
180            }
181            IsolationLevel::None => {
182                // Nothing to stop
183            }
184        }
185        self.container_id = None;
186        self.shell_pid = None;
187        Ok(())
188    }
189
190    /// Execute a command in the space
191    pub async fn exec(&self, command: &[&str]) -> Result<String> {
192        match self.config.isolation {
193            IsolationLevel::Podman => {
194                if let Some(ref id) = self.container_id {
195                    // podman exec {id} {command}
196                    tracing::debug!("Exec in podman {}: {:?}", id, command);
197                }
198            }
199            IsolationLevel::Namespace => {
200                if let Some(pid) = self.shell_pid {
201                    // nsenter -t {pid} -a {command}
202                    tracing::debug!("Exec in namespace {}: {:?}", pid, command);
203                }
204            }
205            IsolationLevel::None => {
206                // Direct execution
207                tracing::debug!("Direct exec: {:?}", command);
208            }
209        }
210        // TODO: Actually execute command
211        Ok(String::new())
212    }
213
214    /// Share terminal with another user
215    pub async fn share_terminal(&self, with: &Identity) -> Result<String> {
216        // Returns a session ID that the other user can join
217        let session_id = format!(
218            "term-{}-{}",
219            self.identity.username,
220            std::time::SystemTime::now()
221                .duration_since(std::time::UNIX_EPOCH)
222                .unwrap()
223                .as_millis()
224        );
225        tracing::info!(
226            "Sharing terminal {} with {}",
227            session_id,
228            with.canonical()
229        );
230        Ok(session_id)
231    }
232
233    /// Update last active timestamp
234    pub fn touch(&mut self) {
235        self.last_active = std::time::SystemTime::now()
236            .duration_since(std::time::UNIX_EPOCH)
237            .unwrap_or_default()
238            .as_secs();
239    }
240
241    /// Check if space has been idle too long
242    pub fn is_idle(&self, timeout_secs: u64) -> bool {
243        let now = std::time::SystemTime::now()
244            .duration_since(std::time::UNIX_EPOCH)
245            .unwrap_or_default()
246            .as_secs();
247        now - self.last_active > timeout_secs
248    }
249}
250
251/// Manager for all active user spaces
252#[derive(Debug, Default)]
253pub struct SpaceManager {
254    /// Active spaces by identity canonical name
255    spaces: std::collections::HashMap<String, UserSpace>,
256}
257
258impl SpaceManager {
259    pub fn new() -> Self {
260        SpaceManager {
261            spaces: std::collections::HashMap::new(),
262        }
263    }
264
265    /// Get or create a space for a user
266    pub async fn get_or_create(
267        &mut self,
268        identity: Identity,
269        config: SpaceConfig,
270    ) -> Result<&mut UserSpace> {
271        let key = identity.canonical();
272        if !self.spaces.contains_key(&key) {
273            let mut space = UserSpace::new(identity, config);
274            space.start().await?;
275            self.spaces.insert(key.clone(), space);
276        }
277        Ok(self.spaces.get_mut(&key).unwrap())
278    }
279
280    /// Get an existing space
281    pub fn get(&self, identity: &Identity) -> Option<&UserSpace> {
282        self.spaces.get(&identity.canonical())
283    }
284
285    /// Remove a user's space
286    pub async fn remove(&mut self, identity: &Identity) -> Result<()> {
287        let key = identity.canonical();
288        if let Some(mut space) = self.spaces.remove(&key) {
289            space.stop().await?;
290        }
291        Ok(())
292    }
293
294    /// List all active spaces
295    pub fn list(&self) -> Vec<&UserSpace> {
296        self.spaces.values().collect()
297    }
298
299    /// Clean up idle spaces
300    pub async fn cleanup_idle(&mut self, timeout_secs: u64) -> Result<usize> {
301        let idle: Vec<String> = self
302            .spaces
303            .iter()
304            .filter(|(_, s)| s.is_idle(timeout_secs))
305            .map(|(k, _)| k.clone())
306            .collect();
307
308        let count = idle.len();
309        for key in idle {
310            if let Some(mut space) = self.spaces.remove(&key) {
311                space.stop().await?;
312            }
313        }
314        Ok(count)
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_space_config_default() {
324        let config = SpaceConfig::default();
325        assert_eq!(config.isolation, IsolationLevel::None);
326        assert!(config.template.is_none());
327    }
328
329    #[test]
330    fn test_user_space_creation() {
331        let identity = Identity::local("test");
332        let space = UserSpace::new(identity.clone(), SpaceConfig::default());
333        assert_eq!(space.identity, identity);
334        assert!(space.container_id.is_none());
335    }
336}