Skip to main content

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

1//! Lifecycle of a tenant's own Docker `PostgreSQL` container.
2//!
3//! Each local tenant owns a compose project under `.systemprompt/docker/`, so
4//! two installations on one host never share a container, a volume, or a role.
5//! Wraps `docker compose` to bring a project up, health-check it, and tear it
6//! down with its volume.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use anyhow::{Context, Result, anyhow, bail};
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::time::Duration;
15use systemprompt_cloud::{DockerCli, ProjectContext};
16
17const LOCAL_DB_USER: &str = "systemprompt";
18const LOCAL_DB_NAME: &str = "systemprompt";
19
20#[derive(Debug, Clone)]
21pub struct TenantContainer {
22    pub project: String,
23    pub password: String,
24    pub port: u16,
25}
26
27impl TenantContainer {
28    #[must_use]
29    pub const fn new(project: String, password: String, port: u16) -> Self {
30        Self {
31            project,
32            password,
33            port,
34        }
35    }
36
37    #[must_use]
38    pub fn compose_path(&self) -> PathBuf {
39        ProjectContext::discover()
40            .docker_dir()
41            .join(format!("{}.yaml", self.project))
42    }
43
44    #[must_use]
45    pub fn database_url(&self) -> String {
46        format!(
47            "postgres://{}:{}@localhost:{}/{}",
48            LOCAL_DB_USER, self.password, self.port, LOCAL_DB_NAME
49        )
50    }
51}
52
53pub fn compose_path_for_project(project: &str) -> PathBuf {
54    ProjectContext::discover()
55        .docker_dir()
56        .join(format!("{project}.yaml"))
57}
58
59pub fn is_project_running(docker: &DockerCli, project: &str) -> bool {
60    let filter = format!("label=com.docker.compose.project={project}");
61    match docker.output(&["ps", "-q", "-f", &filter]) {
62        Ok(out) => !String::from_utf8_lossy(&out.stdout).trim().is_empty(),
63        Err(e) => {
64            tracing::debug!(error = %e, project = %project, "Failed to check container status");
65            false
66        },
67    }
68}
69
70pub async fn start_project(docker: &DockerCli, container: &TenantContainer) -> Result<()> {
71    let compose_path = container.compose_path();
72    if let Some(parent) = compose_path.parent() {
73        fs::create_dir_all(parent).context("Failed to create docker directory")?;
74    }
75
76    fs::write(
77        &compose_path,
78        generate_postgres_compose(&container.password, container.port),
79    )
80    .with_context(|| format!("Failed to write {}", compose_path.display()))?;
81
82    let compose_path_str = compose_path
83        .to_str()
84        .ok_or_else(|| anyhow!("Invalid compose path"))?;
85
86    let status = docker
87        .status(&[
88            "compose",
89            "-p",
90            &container.project,
91            "-f",
92            compose_path_str,
93            "up",
94            "-d",
95        ])
96        .context("Failed to execute docker compose. Is Docker running?")?;
97
98    if !status.success() {
99        bail!("Failed to start PostgreSQL container. Is Docker running?");
100    }
101
102    wait_for_postgres_healthy(docker, &container.project, &compose_path, 60).await
103}
104
105pub fn remove_project(docker: &DockerCli, project: &str) -> Result<()> {
106    let compose_path = compose_path_for_project(project);
107
108    if compose_path.exists() {
109        let compose_path_str = compose_path
110            .to_str()
111            .ok_or_else(|| anyhow!("Invalid compose path"))?;
112
113        let status = docker
114            .status(&[
115                "compose",
116                "-p",
117                project,
118                "-f",
119                compose_path_str,
120                "down",
121                "-v",
122            ])
123            .context("Failed to stop tenant container")?;
124
125        if !status.success() {
126            bail!("Failed to remove container for project '{project}'");
127        }
128
129        fs::remove_file(&compose_path)
130            .with_context(|| format!("Failed to remove {}", compose_path.display()))?;
131    }
132
133    Ok(())
134}
135
136pub async fn wait_for_postgres_healthy(
137    docker: &DockerCli,
138    project: &str,
139    compose_path: &Path,
140    timeout_secs: u64,
141) -> Result<()> {
142    let start = std::time::Instant::now();
143    let compose_path_str = compose_path
144        .to_str()
145        .ok_or_else(|| anyhow!("Invalid compose path"))?;
146
147    loop {
148        let output = docker
149            .output(&[
150                "compose",
151                "-p",
152                project,
153                "-f",
154                compose_path_str,
155                "ps",
156                "--format",
157                "{{.Health}}",
158            ])
159            .context("Failed to check container health")?;
160
161        let health = String::from_utf8_lossy(&output.stdout).trim().to_owned();
162
163        if health.contains("healthy") {
164            return Ok(());
165        }
166
167        if start.elapsed().as_secs() > timeout_secs {
168            bail!(
169                "Timeout waiting for PostgreSQL to become healthy.\nCheck logs with: docker \
170                 compose -p {} -f {} logs",
171                project,
172                compose_path.display()
173            );
174        }
175
176        tokio::time::sleep(Duration::from_secs(2)).await;
177    }
178}
179
180fn generate_postgres_compose(password: &str, port: u16) -> String {
181    format!(
182        r#"# systemprompt.io tenant PostgreSQL container
183# Generated by: systemprompt cloud tenant create
184# Manage with the project name this file was created under.
185
186services:
187  postgres:
188    image: postgres:18-alpine
189    restart: unless-stopped
190    environment:
191      POSTGRES_USER: {LOCAL_DB_USER}
192      POSTGRES_PASSWORD: {password}
193      POSTGRES_DB: {LOCAL_DB_NAME}
194    ports:
195      - "{port}:5432"
196    volumes:
197      - postgres_data:/var/lib/postgresql
198    healthcheck:
199      test: ["CMD-SHELL", "pg_isready -U {LOCAL_DB_USER} -d {LOCAL_DB_NAME}"]
200      interval: 5s
201      timeout: 5s
202      retries: 5
203
204volumes:
205  postgres_data: {{}}
206"#
207    )
208}
209
210pub(in crate::commands::cloud) fn generate_admin_password() -> String {
211    use std::time::{SystemTime, UNIX_EPOCH};
212    let timestamp = SystemTime::now()
213        .duration_since(UNIX_EPOCH)
214        .map_or(1, |d| d.as_nanos());
215    let random_part = format!("{:x}{:x}", timestamp, timestamp.wrapping_mul(31337));
216    random_part.chars().take(32).collect()
217}
218
219pub(in crate::commands::cloud) fn nanoid() -> String {
220    use std::time::{SystemTime, UNIX_EPOCH};
221    let timestamp = SystemTime::now()
222        .duration_since(UNIX_EPOCH)
223        .map_or(1, |d| d.as_millis());
224    format!("{timestamp:x}")
225}
226
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}