Skip to main content

systemprompt_cloud/deploy/provider/
gitops.rs

1//! [`DeployProvider`] for self-managed Kubernetes and GKE tenants.
2//!
3//! The image goes to the registry named in `cloud.deploy` with whatever
4//! credentials the operator's docker client already holds. The rollout is a
5//! commit to the ops repository's working copy that pins the pushed image's
6//! registry digest at `cloud.deploy.gitops.image_digest_key`; the cluster's
7//! `GitOps` controller applies it. Secrets reach the instance through the
8//! cluster's secret store (External Secrets), never from the CLI, so
9//! `push_secrets` only reports the keys the profile expects to find there.
10//!
11//! Copyright (c) systemprompt.io — Business Source License 1.1.
12//! See <https://systemprompt.io> for licensing details.
13
14use std::future::Future;
15use std::path::{Path, PathBuf};
16use std::time::Duration;
17
18use chrono::Utc;
19use systemprompt_manifest::{DeployConfig, HostingProvider};
20use systemprompt_models::net::{HTTP_CONNECT_TIMEOUT, HTTP_DEFAULT_TIMEOUT};
21
22use super::health::{TenantHealth, tenant_base_url, wait_until_ready};
23use super::values_file::set_yaml_scalar;
24use super::{
25    BuiltImage, DeployProvider, RegistryTarget, RolloutReport, SecretBatch, SecretsOutcome,
26    SecretsTransport,
27};
28use crate::docker::{CommandRunner, CommandSpec, DockerCli, image_repository};
29use crate::error::{CloudError, CloudResult};
30
31const READY_POLL_INTERVAL: Duration = Duration::from_secs(5);
32
33#[derive(Debug, Clone)]
34pub struct GitOpsDeployParams {
35    pub provider: HostingProvider,
36    pub tenant_name: String,
37    pub hostname: String,
38    pub deploy: DeployConfig,
39    pub project_root: PathBuf,
40    pub rollout_timeout: Duration,
41}
42
43pub struct GitOpsDeploy {
44    params: GitOpsDeployParams,
45    repo: PathBuf,
46    docker: DockerCli,
47    git: Box<dyn CommandRunner>,
48    http: reqwest::Client,
49}
50
51impl std::fmt::Debug for GitOpsDeploy {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.debug_struct("GitOpsDeploy")
54            .field("params", &self.params)
55            .field("repo", &self.repo)
56            .finish_non_exhaustive()
57    }
58}
59
60impl GitOpsDeploy {
61    pub fn new(
62        params: GitOpsDeployParams,
63        docker: DockerCli,
64        git: Box<dyn CommandRunner>,
65    ) -> CloudResult<Self> {
66        let repo = resolve_repo(&params.project_root, &params.deploy.gitops.repo_path);
67        let http = reqwest::Client::builder()
68            .connect_timeout(HTTP_CONNECT_TIMEOUT)
69            .timeout(HTTP_DEFAULT_TIMEOUT)
70            .build()?;
71        Ok(Self {
72            params,
73            repo,
74            docker,
75            git,
76            http,
77        })
78    }
79
80    #[must_use]
81    pub fn repo(&self) -> &Path {
82        &self.repo
83    }
84
85    fn git(&self, args: &[&str]) -> CloudResult<String> {
86        let spec = CommandSpec::git(&self.repo, args.iter().copied());
87        let output = self.git.output(&spec).map_err(|e| {
88            CloudError::gitops_with(format!("Failed to run: {}", spec.rendered()), e)
89        })?;
90        if !output.status.success() {
91            return Err(CloudError::gitops(format!(
92                "`{}` failed in {}: {}",
93                spec.rendered(),
94                self.repo.display(),
95                String::from_utf8_lossy(&output.stderr).trim()
96            )));
97        }
98        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
99    }
100
101    fn pin_digest(&self, digest: &str) -> CloudResult<(String, Option<String>)> {
102        let gitops = &self.params.deploy.gitops;
103        let branch = self.git(&["rev-parse", "--abbrev-ref", "HEAD"])?;
104        if branch != gitops.branch {
105            return Err(CloudError::gitops(format!(
106                "Ops repository {} is on branch '{branch}', expected '{}'",
107                self.repo.display(),
108                gitops.branch
109            )));
110        }
111        if gitops.push {
112            self.git(&["pull", "--ff-only", "origin", gitops.branch.as_str()])?;
113        }
114
115        let values_path = self.repo.join(&gitops.values_file);
116        let values_display = values_path.display().to_string();
117        let current = std::fs::read_to_string(&values_path)
118            .map_err(|e| CloudError::gitops_with(format!("Failed to read {values_display}"), e))?;
119        let updated =
120            set_yaml_scalar(&current, &gitops.digest_key_segments(), digest).map_err(|source| {
121                CloudError::ValuesFile {
122                    path: values_display.clone(),
123                    source,
124                }
125            })?;
126        if updated == current {
127            let head = self.git(&["rev-parse", "HEAD"])?;
128            return Ok(("unchanged".to_owned(), Some(head)));
129        }
130        std::fs::write(&values_path, updated)
131            .map_err(|e| CloudError::gitops_with(format!("Failed to write {values_display}"), e))?;
132
133        let relative = gitops.values_file.to_string_lossy().into_owned();
134        let message = gitops.render_commit_message(&self.params.tenant_name, digest);
135        self.git(&["add", "--", relative.as_str()])?;
136        self.git(&["commit", "-m", message.as_str(), "--", relative.as_str()])?;
137        let commit = self.git(&["rev-parse", "HEAD"])?;
138        if gitops.push {
139            self.git(&["push", "origin", gitops.branch.as_str()])?;
140            return Ok(("pushed".to_owned(), Some(commit)));
141        }
142        Ok(("committed".to_owned(), Some(commit)))
143    }
144}
145
146fn resolve_repo(project_root: &Path, repo_path: &Path) -> PathBuf {
147    if repo_path.is_absolute() {
148        repo_path.to_path_buf()
149    } else {
150        project_root.join(repo_path)
151    }
152}
153
154impl DeployProvider for GitOpsDeploy {
155    fn provider(&self) -> HostingProvider {
156        self.params.provider
157    }
158
159    fn secrets_transport(&self) -> SecretsTransport {
160        SecretsTransport::External
161    }
162
163    fn registry_credentials(&self) -> impl Future<Output = CloudResult<RegistryTarget>> + Send {
164        let tag = Utc::now().format("%Y%m%d%H%M%S");
165        std::future::ready(Ok(RegistryTarget {
166            image: format!("{}:{tag}", self.params.deploy.image_repository()),
167            login: None,
168        }))
169    }
170
171    fn push_secrets(
172        &self,
173        batch: SecretBatch,
174    ) -> impl Future<Output = CloudResult<SecretsOutcome>> + Send {
175        std::future::ready(Ok(SecretsOutcome::External {
176            required: batch.keys(),
177        }))
178    }
179
180    fn rollout(
181        &self,
182        image: &BuiltImage,
183    ) -> impl Future<Output = CloudResult<RolloutReport>> + Send {
184        std::future::ready(self.pin_rollout(image))
185    }
186
187    async fn status(&self) -> CloudResult<TenantHealth> {
188        wait_until_ready(
189            &self.http,
190            &tenant_base_url(&self.params.hostname),
191            self.params.rollout_timeout,
192            READY_POLL_INTERVAL,
193        )
194        .await
195    }
196}
197
198impl GitOpsDeploy {
199    fn pin_rollout(&self, image: &BuiltImage) -> CloudResult<RolloutReport> {
200        if !image.pushed {
201            return Err(CloudError::gitops(
202                "A GitOps rollout pins the pushed image's registry digest; deploy without \
203                 --skip-push",
204            ));
205        }
206        let digest = self.docker.repo_digest(&image.reference)?;
207        let (status, commit) = self.pin_digest(&digest)?;
208        Ok(RolloutReport {
209            image: format!("{}@{digest}", image_repository(&image.reference)),
210            status,
211            app_url: Some(tenant_base_url(&self.params.hostname)),
212            commit,
213        })
214    }
215}