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