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