Skip to main content

systemprompt_sync/deploy/
orchestrator.rs

1//! Deploy pipeline sequencing.
2//!
3//! Copyright (c) systemprompt.io — Business Source License 1.1.
4//! See <https://systemprompt.io> for licensing details.
5
6use 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    /// Replaces the pre-sync HTTP client; direct sync always targets
43    /// `https://{hostname}`, so tests must substitute a relay-mode client.
44    #[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                &registry_token.registry,
134                &registry_token.username,
135                &registry_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}