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