Skip to main content

systemprompt_cli/commands/cloud/deploy/pipeline/
orchestrator.rs

1//! Deploy pipeline sequencing.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use std::collections::HashMap;
7use std::path::Path;
8
9use anyhow::{Result, bail};
10use systemprompt_cloud::constants::{container, paths};
11use systemprompt_cloud::deploy::{find_services_config, validate_profile_dockerfile};
12use systemprompt_cloud::{CloudApiClient, CloudCredentials, DockerCli, secrets_env};
13use systemprompt_loader::ConfigLoader;
14
15use super::artifacts::DeployArtifacts;
16use super::progress::{DeployEvent, DeployProgress};
17use super::request::{DeployReport, DeployRequest, DeploySecretsSource};
18
19const SIGNING_KEY_ENV: &str = "SIGNING_KEY_PEM";
20const PROFILE_ENV: &str = "SYSTEMPROMPT_PROFILE";
21
22#[derive(Debug, Default)]
23pub struct DeployOrchestrator {
24    docker: DockerCli,
25}
26
27impl DeployOrchestrator {
28    #[must_use]
29    pub fn new() -> Self {
30        Self::default()
31    }
32
33    #[must_use]
34    pub fn with_docker(mut self, docker: DockerCli) -> Self {
35        self.docker = docker;
36        self
37    }
38
39    pub async fn deploy(
40        &self,
41        request: &DeployRequest,
42        progress: &dyn DeployProgress,
43    ) -> Result<DeployReport> {
44        let artifacts = DeployArtifacts::resolve(&request.project_root, &request.profile_name)?;
45        progress.event(&DeployEvent::ArtifactsResolved {
46            tenant_name: &request.tenant_name,
47            binary: &artifacts.binary,
48            dockerfile: &artifacts.dockerfile,
49        });
50
51        let services_config_path = find_services_config(&request.project_root)?;
52        let services_config = ConfigLoader::load_from_path(&services_config_path)?;
53        validate_profile_dockerfile(
54            &artifacts.dockerfile,
55            &request.project_root,
56            &services_config,
57        )?;
58
59        let api_client = CloudApiClient::new(
60            &request.credentials.api_url,
61            request.credentials.api_token.as_str(),
62        )?;
63
64        let image = self
65            .build_and_push(&api_client, request, &artifacts, progress)
66            .await?;
67        provision_secrets(&api_client, request, progress).await?;
68
69        progress.event(&DeployEvent::DeployStarted);
70        let response = api_client.deploy(&request.tenant_id, &image).await?;
71        progress.event(&DeployEvent::Deployed {
72            status: &response.status,
73            app_url: response.app_url.as_deref(),
74        });
75
76        Ok(DeployReport {
77            image,
78            status: response.status,
79            app_url: response.app_url,
80        })
81    }
82
83    async fn build_and_push(
84        &self,
85        api_client: &CloudApiClient,
86        request: &DeployRequest,
87        artifacts: &DeployArtifacts,
88        progress: &dyn DeployProgress,
89    ) -> Result<String> {
90        progress.event(&DeployEvent::RegistryAuthStarted);
91        let registry_token = api_client.get_registry_token(&request.tenant_id).await?;
92        progress.event(&DeployEvent::RegistryAuthFinished);
93
94        let image = format!(
95            "{}/{}:{}",
96            registry_token.registry, registry_token.repository, registry_token.tag
97        );
98        progress.event(&DeployEvent::ImageResolved { image: &image });
99
100        progress.event(&DeployEvent::BuildStarted);
101        self.docker
102            .build_image(&request.project_root, &artifacts.dockerfile, &image)?;
103        progress.event(&DeployEvent::BuildFinished);
104
105        if request.options.skip_push {
106            progress.event(&DeployEvent::PushSkipped);
107        } else {
108            progress.event(&DeployEvent::PushStarted);
109            self.docker.login(
110                &registry_token.registry,
111                &registry_token.username,
112                &registry_token.token,
113            )?;
114            self.docker.push(&image)?;
115            progress.event(&DeployEvent::PushFinished);
116        }
117
118        Ok(image)
119    }
120}
121
122async fn provision_secrets(
123    api_client: &CloudApiClient,
124    request: &DeployRequest,
125    progress: &dyn DeployProgress,
126) -> Result<()> {
127    progress.event(&DeployEvent::SecretsPhaseStarted);
128
129    match &request.secrets {
130        DeploySecretsSource::EnvFromFile { path } => {
131            push_file_secrets(api_client, request, path, progress).await?;
132        },
133        DeploySecretsSource::Vault { bootstrap_env } => {
134            push_vault_bootstrap(api_client, request, bootstrap_env, progress).await?;
135        },
136    }
137
138    progress.event(&DeployEvent::CredentialsSyncStarted);
139    let keys = api_client
140        .set_secrets(&request.tenant_id, credentials_env(&request.credentials))
141        .await?;
142    progress.event(&DeployEvent::CredentialsSynced { count: keys.len() });
143
144    let profile_env_path = format!(
145        "{}/{}/{}",
146        container::PROFILES,
147        request.profile_name,
148        paths::PROFILE_CONFIG
149    );
150    let mut profile_secret = HashMap::new();
151    profile_secret.insert(PROFILE_ENV.to_owned(), profile_env_path);
152    api_client
153        .set_secrets(&request.tenant_id, profile_secret)
154        .await?;
155    progress.event(&DeployEvent::ProfilePathConfigured);
156
157    Ok(())
158}
159
160async fn push_file_secrets(
161    api_client: &CloudApiClient,
162    request: &DeployRequest,
163    path: &Path,
164    progress: &dyn DeployProgress,
165) -> Result<()> {
166    let mut env_secrets = if path.exists() {
167        secrets_env::map_secrets_to_env_vars(secrets_env::load_secrets_json(path)?)
168    } else {
169        progress.event(&DeployEvent::SecretsFileMissing);
170        HashMap::new()
171    };
172
173    if !env_secrets.contains_key(SIGNING_KEY_ENV)
174        && let Some(pem) = secrets_env::read_signing_key_pem(&request.signing_key_path)?
175    {
176        env_secrets.insert(SIGNING_KEY_ENV.to_owned(), pem);
177    }
178
179    if !env_secrets.is_empty() {
180        progress.event(&DeployEvent::SecretsSyncStarted);
181        let keys = api_client
182            .set_secrets(&request.tenant_id, env_secrets)
183            .await?;
184        progress.event(&DeployEvent::SecretsSynced { count: keys.len() });
185    }
186    Ok(())
187}
188
189async fn push_vault_bootstrap(
190    api_client: &CloudApiClient,
191    request: &DeployRequest,
192    bootstrap_env: &[String],
193    progress: &dyn DeployProgress,
194) -> Result<()> {
195    let bootstrap = collect_bootstrap_env(bootstrap_env, |name| std::env::var(name).ok())?;
196    if bootstrap.is_empty() {
197        return Ok(());
198    }
199    progress.event(&DeployEvent::SecretsSyncStarted);
200    let keys = api_client
201        .set_secrets(&request.tenant_id, bootstrap)
202        .await?;
203    progress.event(&DeployEvent::VaultBootstrapSynced { count: keys.len() });
204    Ok(())
205}
206
207pub fn collect_bootstrap_env(
208    names: &[String],
209    lookup: impl Fn(&str) -> Option<String>,
210) -> Result<HashMap<String, String>> {
211    let mut collected = HashMap::new();
212    let mut missing = Vec::new();
213    for name in names {
214        match lookup(name).filter(|value| !value.trim().is_empty()) {
215            Some(value) => {
216                collected.insert(name.clone(), value);
217            },
218            None => missing.push(name.clone()),
219        }
220    }
221    if !missing.is_empty() {
222        bail!(
223            "The profile fetches its secrets from Vault, so the deploy pushes only the Vault \
224             bootstrap credentials. These are not set in this shell: {}.",
225            missing.join(", ")
226        );
227    }
228    Ok(collected)
229}
230
231fn credentials_env(creds: &CloudCredentials) -> HashMap<String, String> {
232    HashMap::from([
233        (
234            "SYSTEMPROMPT_API_TOKEN".to_owned(),
235            creds.api_token.as_str().to_owned(),
236        ),
237        (
238            "SYSTEMPROMPT_USER_EMAIL".to_owned(),
239            creds.user_email.as_str().to_owned(),
240        ),
241        ("SYSTEMPROMPT_CLI_REMOTE".to_owned(), "true".to_owned()),
242    ])
243}