systemprompt_sync/deploy/
orchestrator.rs1use std::collections::HashMap;
4
5use systemprompt_cloud::constants::{container, paths};
6use systemprompt_cloud::deploy::{find_services_config, validate_profile_dockerfile};
7use systemprompt_cloud::{CloudApiClient, CloudCredentials, DockerCli, secrets_env};
8use systemprompt_loader::ConfigLoader;
9
10use crate::api_client::SyncApiClient;
11use crate::error::SyncResult;
12
13use super::artifacts::DeployArtifacts;
14use super::pre_sync::{self, PreSyncOutcome};
15use super::progress::{DeployEvent, DeployProgress};
16use super::request::{DeployOutcome, DeployReport, DeployRequest};
17
18const SIGNING_KEY_ENV: &str = "SIGNING_KEY_PEM";
19const PROFILE_ENV: &str = "SYSTEMPROMPT_PROFILE";
20
21#[derive(Debug, Default)]
22pub struct DeployOrchestrator {
23 docker: DockerCli,
24 sync_client: Option<SyncApiClient>,
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 #[must_use]
42 pub fn with_sync_client(mut self, client: SyncApiClient) -> Self {
43 self.sync_client = Some(client);
44 self
45 }
46
47 pub async fn deploy(
48 &self,
49 request: &DeployRequest,
50 progress: &dyn DeployProgress,
51 ) -> SyncResult<DeployReport> {
52 if let Some(options) = &request.options.pre_sync {
53 let outcome =
54 pre_sync::run(request, options, self.sync_client.clone(), progress).await?;
55 if outcome == PreSyncOutcome::DryRun {
56 return Ok(DeployReport {
57 outcome: DeployOutcome::DryRun,
58 });
59 }
60 }
61
62 let artifacts = DeployArtifacts::resolve(&request.project_root, &request.profile_name)?;
63 progress.event(&DeployEvent::ArtifactsResolved {
64 tenant_name: &request.tenant_name,
65 binary: &artifacts.binary,
66 dockerfile: &artifacts.dockerfile,
67 });
68
69 let services_config_path = find_services_config(&request.project_root)?;
70 let services_config = ConfigLoader::load_from_path(&services_config_path)?;
71 validate_profile_dockerfile(
72 &artifacts.dockerfile,
73 &request.project_root,
74 &services_config,
75 )?;
76
77 let api_client =
78 CloudApiClient::new(&request.credentials.api_url, &request.credentials.api_token)?;
79
80 let image = self
81 .build_and_push(&api_client, request, &artifacts, progress)
82 .await?;
83 provision_secrets(&api_client, request, progress).await?;
84
85 progress.event(&DeployEvent::DeployStarted);
86 let response = api_client.deploy(&request.tenant_id, &image).await?;
87 progress.event(&DeployEvent::Deployed {
88 status: &response.status,
89 app_url: response.app_url.as_deref(),
90 });
91
92 Ok(DeployReport {
93 outcome: DeployOutcome::Deployed {
94 image,
95 status: response.status,
96 app_url: response.app_url,
97 },
98 })
99 }
100
101 async fn build_and_push(
102 &self,
103 api_client: &CloudApiClient,
104 request: &DeployRequest,
105 artifacts: &DeployArtifacts,
106 progress: &dyn DeployProgress,
107 ) -> SyncResult<String> {
108 progress.event(&DeployEvent::RegistryAuthStarted);
109 let registry_token = api_client.get_registry_token(&request.tenant_id).await?;
110 progress.event(&DeployEvent::RegistryAuthFinished);
111
112 let image = format!(
113 "{}/{}:{}",
114 registry_token.registry, registry_token.repository, registry_token.tag
115 );
116 progress.event(&DeployEvent::ImageResolved { image: &image });
117
118 progress.event(&DeployEvent::BuildStarted);
119 self.docker
120 .build_image(&request.project_root, &artifacts.dockerfile, &image)?;
121 progress.event(&DeployEvent::BuildFinished);
122
123 if request.options.skip_push {
124 progress.event(&DeployEvent::PushSkipped);
125 } else {
126 progress.event(&DeployEvent::PushStarted);
127 self.docker.login(
128 ®istry_token.registry,
129 ®istry_token.username,
130 ®istry_token.token,
131 )?;
132 self.docker.push(&image)?;
133 progress.event(&DeployEvent::PushFinished);
134 }
135
136 Ok(image)
137 }
138}
139
140async fn provision_secrets(
141 api_client: &CloudApiClient,
142 request: &DeployRequest,
143 progress: &dyn DeployProgress,
144) -> SyncResult<()> {
145 progress.event(&DeployEvent::SecretsPhaseStarted);
146
147 let mut env_secrets = if request.secrets_path.exists() {
148 secrets_env::map_secrets_to_env_vars(secrets_env::load_secrets_json(&request.secrets_path)?)
149 } else {
150 progress.event(&DeployEvent::SecretsFileMissing);
151 HashMap::new()
152 };
153
154 if !env_secrets.contains_key(SIGNING_KEY_ENV)
155 && let Some(pem) = secrets_env::read_signing_key_pem(&request.signing_key_path)?
156 {
157 env_secrets.insert(SIGNING_KEY_ENV.to_owned(), pem);
158 }
159
160 if !env_secrets.is_empty() {
161 progress.event(&DeployEvent::SecretsSyncStarted);
162 let keys = api_client
163 .set_secrets(&request.tenant_id, env_secrets)
164 .await?;
165 progress.event(&DeployEvent::SecretsSynced { count: keys.len() });
166 }
167
168 progress.event(&DeployEvent::CredentialsSyncStarted);
169 let keys = api_client
170 .set_secrets(&request.tenant_id, credentials_env(&request.credentials))
171 .await?;
172 progress.event(&DeployEvent::CredentialsSynced { count: keys.len() });
173
174 let profile_env_path = format!(
175 "{}/{}/{}",
176 container::PROFILES,
177 request.profile_name,
178 paths::PROFILE_CONFIG
179 );
180 let mut profile_secret = HashMap::new();
181 profile_secret.insert(PROFILE_ENV.to_owned(), profile_env_path);
182 api_client
183 .set_secrets(&request.tenant_id, profile_secret)
184 .await?;
185 progress.event(&DeployEvent::ProfilePathConfigured);
186
187 Ok(())
188}
189
190fn credentials_env(creds: &CloudCredentials) -> HashMap<String, String> {
191 HashMap::from([
192 ("SYSTEMPROMPT_API_TOKEN".to_owned(), creds.api_token.clone()),
193 (
194 "SYSTEMPROMPT_USER_EMAIL".to_owned(),
195 creds.user_email.clone(),
196 ),
197 ("SYSTEMPROMPT_CLI_REMOTE".to_owned(), "true".to_owned()),
198 ])
199}