systemprompt_cli/commands/cloud/tenant/docker/
container.rs1use 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}