Skip to main content

systemprompt_cli/commands/cloud/tenant/docker/
container.rs

1use anyhow::{Context, Result, anyhow, bail};
2use std::fs;
3use std::path::Path;
4use std::process::Command;
5use std::time::Duration;
6use systemprompt_cloud::ProjectContext;
7use systemprompt_logging::CliService;
8
9use super::config::{SHARED_CONTAINER_NAME, SHARED_VOLUME_NAME, shared_config_path};
10
11pub fn is_shared_container_running() -> bool {
12    let output = Command::new("docker")
13        .args(["ps", "-q", "-f", &format!("name={}", SHARED_CONTAINER_NAME)])
14        .output();
15
16    match output {
17        Ok(out) => !String::from_utf8_lossy(&out.stdout).trim().is_empty(),
18        Err(e) => {
19            tracing::debug!(error = %e, "Failed to check shared container status");
20            false
21        },
22    }
23}
24
25pub fn get_container_password() -> Option<String> {
26    let output = Command::new("docker")
27        .args([
28            "inspect",
29            SHARED_CONTAINER_NAME,
30            "--format",
31            "{{range .Config.Env}}{{println .}}{{end}}",
32        ])
33        .output();
34
35    match output {
36        Ok(out) if out.status.success() => {
37            let env_vars = String::from_utf8_lossy(&out.stdout);
38            for line in env_vars.lines() {
39                if let Some(password) = line.strip_prefix("POSTGRES_PASSWORD=") {
40                    return Some(password.to_string());
41                }
42            }
43            None
44        },
45        Ok(_out) => {
46            tracing::debug!("Docker inspect returned non-success exit code");
47            None
48        },
49        Err(e) => {
50            tracing::debug!(error = %e, "Failed to inspect container");
51            None
52        },
53    }
54}
55
56pub fn check_volume_exists() -> bool {
57    let output = Command::new("docker")
58        .args([
59            "volume",
60            "ls",
61            "-q",
62            "-f",
63            &format!("name={}", SHARED_VOLUME_NAME),
64        ])
65        .output();
66
67    match output {
68        Ok(out) => !String::from_utf8_lossy(&out.stdout).trim().is_empty(),
69        Err(e) => {
70            tracing::debug!(error = %e, "Failed to check volume existence");
71            false
72        },
73    }
74}
75
76pub fn remove_shared_volume() -> Result<()> {
77    let status = Command::new("docker")
78        .args(["volume", "rm", SHARED_VOLUME_NAME])
79        .status()
80        .context("Failed to remove PostgreSQL volume")?;
81
82    if !status.success() {
83        bail!(
84            "Failed to remove volume '{}'. Is a container still using it?",
85            SHARED_VOLUME_NAME
86        );
87    }
88
89    Ok(())
90}
91
92pub fn stop_shared_container() -> Result<()> {
93    let ctx = ProjectContext::discover();
94    let compose_path = ctx.docker_dir().join("shared.yaml");
95
96    if compose_path.exists() {
97        let compose_path_str = compose_path
98            .to_str()
99            .ok_or_else(|| anyhow!("Invalid compose path"))?;
100
101        CliService::info("Stopping shared PostgreSQL container...");
102        let status = Command::new("docker")
103            .args(["compose", "-f", compose_path_str, "down", "-v"])
104            .status()
105            .context("Failed to stop shared container")?;
106
107        if !status.success() {
108            CliService::warning("Failed to stop container via compose, trying direct stop");
109        }
110    }
111
112    let output = Command::new("docker")
113        .args([
114            "ps",
115            "-aq",
116            "-f",
117            &format!("name={}", SHARED_CONTAINER_NAME),
118        ])
119        .output()?;
120
121    let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
122    if !container_id.is_empty() {
123        Command::new("docker")
124            .args(["stop", &container_id])
125            .status()?;
126        Command::new("docker")
127            .args(["rm", &container_id])
128            .status()?;
129    }
130
131    let config_path = shared_config_path();
132    if config_path.exists() {
133        fs::remove_file(&config_path)?;
134    }
135
136    CliService::success("Shared PostgreSQL container removed");
137    Ok(())
138}
139
140pub async fn wait_for_postgres_healthy(compose_path: &Path, timeout_secs: u64) -> Result<()> {
141    let start = std::time::Instant::now();
142    let compose_path_str = compose_path
143        .to_str()
144        .ok_or_else(|| anyhow!("Invalid compose path"))?;
145
146    loop {
147        let output = Command::new("docker")
148            .args([
149                "compose",
150                "-f",
151                compose_path_str,
152                "ps",
153                "--format",
154                "{{.Health}}",
155            ])
156            .output()
157            .context("Failed to check container health")?;
158
159        let health = String::from_utf8_lossy(&output.stdout).trim().to_string();
160
161        if health.contains("healthy") {
162            return Ok(());
163        }
164
165        if start.elapsed().as_secs() > timeout_secs {
166            bail!(
167                "Timeout waiting for PostgreSQL to become healthy.\nCheck logs with: docker \
168                 compose -f {} logs",
169                compose_path.display()
170            );
171        }
172
173        tokio::time::sleep(Duration::from_secs(2)).await;
174    }
175}
176
177pub fn generate_shared_postgres_compose(password: &str, port: u16) -> String {
178    format!(
179        r#"# systemprompt.io Shared PostgreSQL Container
180# Generated by: systemprompt cloud tenant create
181# Manage: docker compose -f .systemprompt/docker/shared.yaml up/down
182
183services:
184  postgres:
185    image: postgres:18-alpine
186    container_name: {container_name}
187    restart: unless-stopped
188    environment:
189      POSTGRES_USER: {admin_user}
190      POSTGRES_PASSWORD: {password}
191      POSTGRES_DB: postgres
192    ports:
193      - "{port}:5432"
194    volumes:
195      - {volume_name}:/var/lib/postgresql
196    healthcheck:
197      test: ["CMD-SHELL", "pg_isready -U {admin_user}"]
198      interval: 5s
199      timeout: 5s
200      retries: 5
201
202volumes:
203  {volume_name}:
204    name: {volume_name}
205"#,
206        container_name = SHARED_CONTAINER_NAME,
207        admin_user = super::config::SHARED_ADMIN_USER,
208        password = password,
209        port = port,
210        volume_name = SHARED_VOLUME_NAME
211    )
212}
213
214pub fn generate_admin_password() -> String {
215    use std::time::{SystemTime, UNIX_EPOCH};
216    let timestamp = SystemTime::now()
217        .duration_since(UNIX_EPOCH)
218        .map_or(1, |d| d.as_nanos());
219    let random_part = format!("{:x}{:x}", timestamp, timestamp.wrapping_mul(31337));
220    random_part.chars().take(32).collect()
221}
222
223pub fn nanoid() -> String {
224    use std::time::{SystemTime, UNIX_EPOCH};
225    let timestamp = SystemTime::now()
226        .duration_since(UNIX_EPOCH)
227        .map_or(1, |d| d.as_millis());
228    format!("{:x}", timestamp)
229}