Skip to main content

systemprompt_cli/commands/cloud/profile/
templates.rs

1//! Writers for the on-disk artifacts of a created profile.
2//!
3//! Persists the profile YAML, Dockerfile, entrypoint, dockerignore, and
4//! secrets file (with restrictive permissions), and updates the AI config's
5//! default provider.
6//!
7//! Copyright (c) systemprompt.io — Business Source License 1.1.
8//! See <https://systemprompt.io> for licensing details.
9
10use anyhow::{Context, Result};
11use regex::Regex;
12use std::path::Path;
13use systemprompt_cloud::constants::container;
14use systemprompt_logging::CliService;
15use systemprompt_models::{CliPaths, Profile};
16
17use crate::commands::cloud::init::templates::ai_config;
18
19use systemprompt_cloud::deploy::DockerfileBuilder;
20
21use crate::shared::profile::generate_oauth_at_rest_pepper;
22
23pub fn existing_geoip_database(profile_path: &Path) -> Option<String> {
24    let content = std::fs::read_to_string(profile_path).ok()?;
25    let geoip = match Profile::from_yaml(&content, profile_path) {
26        Ok(profile) => profile.paths.geoip_database,
27        Err(e) => {
28            tracing::warn!(
29                error = %e,
30                path = %profile_path.display(),
31                "existing profile unparseable; not carrying geoip_database forward"
32            );
33            None
34        },
35    }?;
36    if !geoip.starts_with(container::APP) {
37        tracing::warn!(
38            geoip_database = %geoip,
39            container_root = container::APP,
40            "geoip_database points outside the container app root; the container must provide \
41             this path or profile validation will fail at boot"
42        );
43    }
44    Some(geoip)
45}
46
47pub fn save_profile(profile: &Profile, profile_path: &Path) -> Result<()> {
48    let header = format!(
49        "# systemprompt.io Profile: {}\n# Generated by 'systemprompt cloud profile create'",
50        profile.display_name
51    );
52
53    crate::shared::profile::save_profile_yaml(profile, profile_path, Some(&header))
54}
55
56pub fn save_dockerfile(path: &Path, profile_name: &str, project_root: &Path) -> Result<()> {
57    let content = DockerfileBuilder::new(project_root)
58        .with_profile(profile_name)
59        .build();
60
61    std::fs::write(path, &content)
62        .with_context(|| format!("Failed to write {}", path.display()))?;
63
64    Ok(())
65}
66
67pub fn save_entrypoint(path: &Path) -> Result<()> {
68    let content = format!(
69        r"#!/bin/sh
70set -e
71
72exec {bin}/systemprompt {services_serve_cmd} --foreground
73",
74        bin = container::BIN,
75        services_serve_cmd = CliPaths::services_serve_cmd(),
76    );
77
78    if let Some(parent) = path.parent() {
79        std::fs::create_dir_all(parent)
80            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
81    }
82
83    std::fs::write(path, &content)
84        .with_context(|| format!("Failed to write {}", path.display()))?;
85
86    #[cfg(unix)]
87    {
88        use std::os::unix::fs::PermissionsExt;
89        let permissions = std::fs::Permissions::from_mode(0o755);
90        std::fs::set_permissions(path, permissions)
91            .with_context(|| format!("Failed to set permissions on {}", path.display()))?;
92    }
93
94    Ok(())
95}
96
97pub fn save_dockerignore(path: &Path) -> Result<()> {
98    let content = r".git
99.gitignore
100.gitmodules
101target/debug
102target/release/.fingerprint
103target/release/build
104target/release/deps
105target/release/examples
106target/release/incremental
107target/release/.cargo-lock
108.cargo
109.systemprompt/credentials.json
110.systemprompt/tenants.json
111.systemprompt/**/secrets.json
112.systemprompt/docker
113.systemprompt/storage
114.env*
115backup
116docs
117instructions
118*.md
119web/node_modules
120.vscode
121.idea
122logs
123*.log
124plan
125";
126
127    if let Some(parent) = path.parent() {
128        std::fs::create_dir_all(parent)
129            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
130    }
131
132    std::fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))?;
133
134    Ok(())
135}
136
137#[derive(Debug)]
138pub struct DatabaseUrls<'a> {
139    pub external: &'a str,
140    pub internal: Option<&'a str>,
141}
142
143pub fn save_secrets(
144    db_urls: &DatabaseUrls<'_>,
145    api_keys: &super::api_keys::ApiKeys,
146    secrets_path: &Path,
147    _is_cloud_tenant: bool,
148) -> Result<()> {
149    use serde_json::json;
150    use systemprompt_models::Profile;
151
152    if Profile::is_masked_database_url(db_urls.external) {
153        CliService::warning(
154            "Database URL appears to be masked. Credentials may not work correctly.",
155        );
156        CliService::warning(
157            "Run 'systemprompt cloud tenant refresh-credentials' to fetch real credentials.",
158        );
159    }
160
161    if let Some(internal) = db_urls.internal
162        && Profile::is_masked_database_url(internal)
163    {
164        CliService::warning(
165            "Internal database URL appears to be masked. Credentials may not work correctly.",
166        );
167    }
168
169    if let Some(parent) = secrets_path.parent() {
170        std::fs::create_dir_all(parent)
171            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
172    }
173
174    let mut secrets = json!({
175        "oauth_at_rest_pepper": generate_oauth_at_rest_pepper(),
176        "database_url": db_urls.external,
177        "external_database_url": db_urls.external,
178        "gemini": api_keys.gemini,
179        "anthropic": api_keys.anthropic,
180        "openai": api_keys.openai
181    });
182
183    if let Some(internal) = db_urls.internal {
184        secrets["internal_database_url"] = json!(internal);
185    }
186
187    let content = serde_json::to_string_pretty(&secrets).context("Failed to serialize secrets")?;
188
189    std::fs::write(secrets_path, content)
190        .with_context(|| format!("Failed to write {}", secrets_path.display()))?;
191
192    #[cfg(unix)]
193    {
194        use std::os::unix::fs::PermissionsExt;
195        let permissions = std::fs::Permissions::from_mode(0o600);
196        std::fs::set_permissions(secrets_path, permissions)
197            .with_context(|| format!("Failed to set permissions on {}", secrets_path.display()))?;
198    }
199
200    Ok(())
201}
202
203pub fn get_services_path() -> Result<String> {
204    if let Ok(path) = std::env::var("SYSTEMPROMPT_SERVICES_PATH") {
205        return Ok(path);
206    }
207
208    let cwd = std::env::current_dir().context("Failed to get current directory")?;
209    let services_path = cwd.join("services");
210
211    Ok(services_path.to_string_lossy().to_string())
212}
213
214pub async fn validate_connection(db_url: &str) -> bool {
215    use tokio::time::{Duration, timeout};
216
217    let result = timeout(Duration::from_secs(5), async {
218        sqlx::postgres::PgPoolOptions::new()
219            .max_connections(1)
220            .connect(db_url)
221            .await
222    })
223    .await;
224
225    matches!(result, Ok(Ok(_)))
226}
227
228pub fn run_migrations_cmd(profile_path: &Path) -> Result<()> {
229    use std::process::Command;
230
231    CliService::info("Running database migrations...");
232
233    let current_exe = std::env::current_exe().context("Failed to get executable path")?;
234    let profile_path_str = profile_path.to_string_lossy();
235
236    let output = Command::new(&current_exe)
237        .args(CliPaths::db_migrate_args())
238        .env("SYSTEMPROMPT_PROFILE", profile_path_str.as_ref())
239        .output()
240        .context("Failed to run migrations")?;
241
242    if output.status.success() {
243        CliService::success("Migrations completed");
244        return Ok(());
245    }
246
247    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned();
248    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
249
250    let error_output = if !stderr.is_empty() {
251        stderr
252    } else if !stdout.is_empty() {
253        stdout
254    } else {
255        "Unknown error (no output)".to_owned()
256    };
257
258    anyhow::bail!("Migration failed: {}", error_output)
259}
260
261pub fn update_ai_config_default_provider(provider: &str) -> Result<()> {
262    let services_path = get_services_path()?;
263    let ai_dir = Path::new(&services_path).join("ai");
264    let ai_config_path = ai_dir.join("config.yaml");
265
266    if !ai_config_path.exists() {
267        CliService::warning("AI config not found. Creating services/ai/config.yaml");
268        CliService::info("Run 'systemprompt cloud init' for full project setup");
269
270        std::fs::create_dir_all(&ai_dir)
271            .with_context(|| format!("Failed to create directory {}", ai_dir.display()))?;
272        std::fs::write(&ai_config_path, ai_config(provider))
273            .with_context(|| format!("Failed to write {}", ai_config_path.display()))?;
274        CliService::success(&format!("Created: {}", ai_config_path.display()));
275        return Ok(());
276    }
277
278    let content = std::fs::read_to_string(&ai_config_path)
279        .with_context(|| format!("Failed to read {}", ai_config_path.display()))?;
280    let re = Regex::new(r#"default_provider:\s*"?\w+"?"#).context("Failed to compile regex")?;
281    let updated = re.replace(&content, format!(r#"default_provider: "{}""#, provider));
282
283    std::fs::write(&ai_config_path, updated.as_ref())
284        .with_context(|| format!("Failed to write {}", ai_config_path.display()))?;
285    CliService::success(&format!(
286        "Updated default_provider to '{}' in AI config",
287        provider
288    ));
289    Ok(())
290}