Skip to main content

systemprompt_sync/deploy/
orchestrator.rs

1//! Deploy pipeline sequencing.
2
3use 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    /// Replaces the pre-sync HTTP client; direct sync always targets
40    /// `https://{hostname}`, so tests must substitute a relay-mode client.
41    #[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                &registry_token.registry,
131                &registry_token.username,
132                &registry_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}