systemprompt_cli/commands/cloud/deploy/pipeline/
orchestrator.rs1use 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 ®istry_token.registry,
111 ®istry_token.username,
112 ®istry_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}