systemprompt_cloud/deploy/provider/
gitops.rs1use 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(¶ms.project_root, ¶ms.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(¤t, &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}