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