Skip to main content

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

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