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