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 = CloudApiClient::new(
78 &request.credentials.api_url,
79 request.credentials.api_token.as_str(),
80 )?;
81
82 let image = self
83 .build_and_push(&api_client, request, &artifacts, progress)
84 .await?;
85 provision_secrets(&api_client, request, progress).await?;
86
87 progress.event(&DeployEvent::DeployStarted);
88 let response = api_client.deploy(&request.tenant_id, &image).await?;
89 progress.event(&DeployEvent::Deployed {
90 status: &response.status,
91 app_url: response.app_url.as_deref(),
92 });
93
94 Ok(DeployReport {
95 outcome: DeployOutcome::Deployed {
96 image,
97 status: response.status,
98 app_url: response.app_url,
99 },
100 })
101 }
102
103 async fn build_and_push(
104 &self,
105 api_client: &CloudApiClient,
106 request: &DeployRequest,
107 artifacts: &DeployArtifacts,
108 progress: &dyn DeployProgress,
109 ) -> SyncResult<String> {
110 progress.event(&DeployEvent::RegistryAuthStarted);
111 let registry_token = api_client.get_registry_token(&request.tenant_id).await?;
112 progress.event(&DeployEvent::RegistryAuthFinished);
113
114 let image = format!(
115 "{}/{}:{}",
116 registry_token.registry, registry_token.repository, registry_token.tag
117 );
118 progress.event(&DeployEvent::ImageResolved { image: &image });
119
120 progress.event(&DeployEvent::BuildStarted);
121 self.docker
122 .build_image(&request.project_root, &artifacts.dockerfile, &image)?;
123 progress.event(&DeployEvent::BuildFinished);
124
125 if request.options.skip_push {
126 progress.event(&DeployEvent::PushSkipped);
127 } else {
128 progress.event(&DeployEvent::PushStarted);
129 self.docker.login(
130 ®istry_token.registry,
131 ®istry_token.username,
132 ®istry_token.token,
133 )?;
134 self.docker.push(&image)?;
135 progress.event(&DeployEvent::PushFinished);
136 }
137
138 Ok(image)
139 }
140}
141
142async fn provision_secrets(
143 api_client: &CloudApiClient,
144 request: &DeployRequest,
145 progress: &dyn DeployProgress,
146) -> SyncResult<()> {
147 progress.event(&DeployEvent::SecretsPhaseStarted);
148
149 let mut env_secrets = if request.secrets_path.exists() {
150 secrets_env::map_secrets_to_env_vars(secrets_env::load_secrets_json(&request.secrets_path)?)
151 } else {
152 progress.event(&DeployEvent::SecretsFileMissing);
153 HashMap::new()
154 };
155
156 if !env_secrets.contains_key(SIGNING_KEY_ENV)
157 && let Some(pem) = secrets_env::read_signing_key_pem(&request.signing_key_path)?
158 {
159 env_secrets.insert(SIGNING_KEY_ENV.to_owned(), pem);
160 }
161
162 if !env_secrets.is_empty() {
163 progress.event(&DeployEvent::SecretsSyncStarted);
164 let keys = api_client
165 .set_secrets(&request.tenant_id, env_secrets)
166 .await?;
167 progress.event(&DeployEvent::SecretsSynced { count: keys.len() });
168 }
169
170 progress.event(&DeployEvent::CredentialsSyncStarted);
171 let keys = api_client
172 .set_secrets(&request.tenant_id, credentials_env(&request.credentials))
173 .await?;
174 progress.event(&DeployEvent::CredentialsSynced { count: keys.len() });
175
176 let profile_env_path = format!(
177 "{}/{}/{}",
178 container::PROFILES,
179 request.profile_name,
180 paths::PROFILE_CONFIG
181 );
182 let mut profile_secret = HashMap::new();
183 profile_secret.insert(PROFILE_ENV.to_owned(), profile_env_path);
184 api_client
185 .set_secrets(&request.tenant_id, profile_secret)
186 .await?;
187 progress.event(&DeployEvent::ProfilePathConfigured);
188
189 Ok(())
190}
191
192fn credentials_env(creds: &CloudCredentials) -> HashMap<String, String> {
193 HashMap::from([
194 (
195 "SYSTEMPROMPT_API_TOKEN".to_owned(),
196 creds.api_token.as_str().to_owned(),
197 ),
198 (
199 "SYSTEMPROMPT_USER_EMAIL".to_owned(),
200 creds.user_email.as_str().to_owned(),
201 ),
202 ("SYSTEMPROMPT_CLI_REMOTE".to_owned(), "true".to_owned()),
203 ])
204}