Skip to content
Clone

refactor: remove search, sync/import, webhook and CI

- drop searchhost, synchost, webhook and runner apps plus search/import/ci/ci-protocol crates
- remove global and chat search, import API/UI, pipelines and repository webhooks
- clean database schema, config, Helm, Docker, dev scripts and generated OpenAPI/types
- clear compiler warnings across the workspace
ZhenYi committed
parent 5f7b107 commit 951a522 Browse files

280 files changed

+130 −18056

This change is very large, so some diffs are not shown. Clone the repository to see the full change.

File tree 280
Deleted app/runner/src/actions.rs
@@ -1,231 +0,0 @@
1use ci_protocol::actions::EngineEvent;
2use ci_protocol::runner::{
3 JobFinishRequest, JobFinishState, JobPayload, LogChunk, LogChunksRequest,
4};
5use config::runner::RunnerConfig;
6use serde_json::json;
7use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
8use tokio::process::Command;
9
10use crate::client::RunnerClient;
11
12pub fn available(config: &RunnerConfig) -> bool {
13 use std::io::Write;
14 let Ok(mut child) = std::process::Command::new(&config.actions_engine_binary)
15 .stdin(std::process::Stdio::piped())
16 .stdout(std::process::Stdio::null())
17 .stderr(std::process::Stdio::null())
18 .spawn()
19 else {
20 return false;
21 };
22 if let Some(mut input) = child.stdin.take() {
23 let _ = input.write_all(b"{\"op\":\"version\"}\n");
24 }
25 child.wait().is_ok_and(|status| status.success())
26}
27
28pub async fn execute(
29 client: &RunnerClient,
30 config: &RunnerConfig,
31 payload: &JobPayload,
32) -> JobFinishRequest {
33 let mut finish = match execute_inner(client, config, payload).await {
34 Ok(finish) => finish,
35 Err(error) => JobFinishRequest {
36 state: JobFinishState::Failure,
37 exit_code: Some(1),
38 error: Some(error.to_string()),
39 ..Default::default()
40 },
41 };
42 finish.lease_token = Some(payload.lease.token.clone());
43 finish
44}
45
46async fn execute_inner(
47 client: &RunnerClient,
48 config: &RunnerConfig,
49 payload: &JobPayload,
50) -> anyhow::Result<JobFinishRequest> {
51 let actions = payload
52 .actions
53 .as_ref()
54 .ok_or_else(|| anyhow::anyhow!("Actions execution payload missing"))?;
55 client.start(&payload.job_id).await?;
56 let workspace = std::path::Path::new(&config.workspace_root)
57 .join(payload.run_id.to_string())
58 .join(payload.job_id.to_string());
59 let repo = workspace.join("repo");
60 tokio::fs::create_dir_all(&repo).await?;
61 let request = json!({
62 "op":"execute", "source":actions.workflow.source, "file":actions.workflow.file,
63 "job":actions.job, "matrix":actions.matrix, "github":actions.github, "event":actions.event,
64 "needs":actions.needs, "inputs":actions.inputs, "vars":actions.vars, "secrets":payload.secrets,
65 "env":payload.env, "workdir":repo, "platforms":config.actions_platforms,
66 "node_runtimes":config.actions_node_runtimes, "action_cache_dir":config.actions_cache_dir,
67 "action_server":config.actions_action_server, "action_token":config.actions_action_token,
68 "token":actions.token, "runtime_url":actions.runtime_url, "runtime_token":actions.runtime_token,
69 "timeout_seconds":payload.timeout_secs,
70 });
71 let mut command = Command::new(&config.actions_engine_binary);
72 command
73 .stdin(std::process::Stdio::piped())
74 .stdout(std::process::Stdio::piped())
75 .stderr(std::process::Stdio::piped())
76 .kill_on_drop(true);
77 #[cfg(unix)]
78 command.process_group(0);
79 let mut child = command.spawn()?;
80 let mut input = child
81 .stdin
82 .take()
83 .ok_or_else(|| anyhow::anyhow!("engine stdin missing"))?;
84 input.write_all(&serde_json::to_vec(&request)?).await?;
85 input.write_all(b"\n").await?;
86 drop(input);
87 let stderr = child
88 .stderr
89 .take()
90 .ok_or_else(|| anyhow::anyhow!("engine stderr missing"))?;
91 let stderr_task = tokio::spawn(async move {
92 let mut reader = BufReader::new(stderr).lines();
93 let mut tail = String::new();
94 while let Ok(Some(line)) = reader.next_line().await {
95 if tail.len() < 65536 {
96 tail.push_str(&line);
97 tail.push('\n');
98 }
99 }
100 tail
101 });
102 let renew_client = client.clone();
103 let job_id = payload.job_id;
104 let interval = payload.lease.heartbeat_interval_secs.max(1);
105 let request_id = export::telemetry::request_id();
106 let renew = tokio::spawn(async move {
107 let mut ticker = tokio::time::interval(std::time::Duration::from_secs(interval));
108 loop {
109 ticker.tick().await;
110 if export::middleware::with_request_id(
111 request_id.as_deref(),
112 renew_client.renew(&job_id),
113 )
114 .await
115 .is_err()
116 {
117 crate::executor::request_cancel(&[job_id]);
118 }
119 }
120 });
121 let mut stdout = BufReader::new(
122 child
123 .stdout
124 .take()
125 .ok_or_else(|| anyhow::anyhow!("engine stdout missing"))?,
126 )
127 .lines();
128 let mut ticker = tokio::time::interval(std::time::Duration::from_millis(250));
129 let mut seq = 0;
130 let mut logs = String::new();
131 let mut result = None;
132 let mut cancelled_at = None;
133 let deadline = tokio::time::Instant::now()
134 + std::time::Duration::from_secs(payload.timeout_secs.saturating_add(90));
135 let processing: anyhow::Result<()> = async {
136 loop {
137 tokio::select! {
138 line = stdout.next_line() => {
139 let Some(line)=line? else {break};
140 let mut event:EngineEvent=serde_json::from_str(&line)?;
141 event.lease_token=Some(payload.lease.token.clone());
142 match event.event.as_str() {
143 "log" => { logs.push_str(event.data["text"].as_str().unwrap_or_default()); if logs.len() >= config.log_flush_bytes { flush(client,payload,&mut logs,&mut seq).await?; } }
144 "result" => { if let Some(error)=event.error { anyhow::bail!("{error}"); } result=Some(event.data); }
145 _ => { flush(client,payload,&mut logs,&mut seq).await?; client.event(payload.job_id,&event).await?; }
146 }
147 }
148 _ = ticker.tick() => {
149 flush(client,payload,&mut logs,&mut seq).await?;
150 if crate::executor::is_cancelled(&payload.job_id) && cancelled_at.is_none() {
151 cancelled_at=Some(tokio::time::Instant::now());
152 #[cfg(unix)]
153 if let Some(pid)=child.id() { unsafe extern "C" {fn kill(pid:i32,signal:i32)->i32;} unsafe {kill(pid as i32,15);} }
154 #[cfg(not(unix))]
155 child.kill().await?;
156 }
157 if tokio::time::Instant::now()>deadline || cancelled_at.is_some_and(|at| at.elapsed().as_secs()>90) { child.kill().await?; anyhow::bail!("Actions engine cleanup deadline exceeded"); }
158 }
159 }
160 }
161 flush(client,payload,&mut logs,&mut seq).await?;
162 Ok(())
163 }.await;
164 renew.abort();
165 if processing.is_err() {
166 let _ = child.kill().await;
167 }
168 let status = child.wait().await?;
169 let stderr = stderr_task.await.unwrap_or_default();
170 if !config.keep_workspace {
171 let _ = tokio::fs::remove_dir_all(&workspace).await;
172 }
173 processing?;
174 if !status.success() {
175 anyhow::bail!("Actions engine exited {status}: {stderr}");
176 }
177 let result =
178 result.ok_or_else(|| anyhow::anyhow!("Actions engine returned no job result: {stderr}"))?;
179 let state = match result["result"].as_str().unwrap_or("failure") {
180 "success" | "skipped" => JobFinishState::Success,
181 "cancelled" => JobFinishState::Cancelled,
182 "timed_out" => JobFinishState::TimedOut,
183 _ => JobFinishState::Failure,
184 };
185 Ok(JobFinishRequest {
186 state,
187 exit_code: Some(if state == JobFinishState::Success {
188 0
189 } else {
190 1
191 }),
192 error: result["error"]
193 .as_str()
194 .filter(|v| !v.is_empty())
195 .map(str::to_owned),
196 outputs: serde_json::from_value(result["outputs"].clone()).unwrap_or_default(),
197 lease_token: Some(payload.lease.token.clone()),
198 })
199}
200
201async fn flush(
202 client: &RunnerClient,
203 payload: &JobPayload,
204 logs: &mut String,
205 seq: &mut i32,
206) -> anyhow::Result<()> {
207 if logs.is_empty() {
208 return Ok(());
209 }
210 let mut text = std::mem::take(logs);
211 for secret in payload.secrets.values().chain(
212 payload
213 .actions
214 .iter()
215 .flat_map(|actions| [&actions.token, &actions.runtime_token]),
216 ) {
217 if !secret.is_empty() {
218 text = text.replace(secret, "***");
219 }
220 }
221 client
222 .upload_logs(
223 payload.job_id,
224 &LogChunksRequest {
225 chunks: vec![LogChunk { seq: *seq, text }],
226 },
227 )
228 .await?;
229 *seq += 1;
230 Ok(())
231}
Deleted app/runner/src/client.rs
@@ -1,143 +0,0 @@
1use anyhow::Context;
2use reqwest::StatusCode;
3use uuid::Uuid;
4
5use ci_protocol::runner::{
6 ArtifactCompleteRequest, ArtifactInitRequest, ArtifactInitResponse, ClaimRequest,
7 ClaimResponse, HeartbeatRequest, HeartbeatResponse, JobFinishRequest, LogChunksRequest,
8 RegisterRequest, RegisterResponse, StepReport,
9};
10use config::runner::RunnerConfig;
11
12#[derive(Clone)]
13pub struct RunnerClient {
14 http: reqwest::Client,
15 base_url: String,
16 credential: Option<String>,
17}
18
19impl RunnerClient {
20 pub fn new(cfg: &RunnerConfig) -> anyhow::Result<Self> {
21 let http = reqwest::Client::builder()
22 .timeout(std::time::Duration::from_secs(120))
23 .build()
24 .context("building http client")?;
25 Ok(Self {
26 http,
27 base_url: cfg.server_url.trim_end_matches('/').to_string(),
28 credential: None,
29 })
30 }
31
32 #[must_use]
33 pub fn with_credential(mut self, credential: String) -> Self {
34 self.credential = Some(credential);
35 self
36 }
37
38 fn url(&self, path: &str) -> String {
39 format!("{}/api/v1/runner{path}", self.base_url)
40 }
41
42 async fn post<B: serde::Serialize, T: serde::de::DeserializeOwned>(
43 &self,
44 path: &str,
45 body: &B,
46 ) -> anyhow::Result<T> {
47 let url = self.url(path);
48 let mut request = self.http.post(&url).json(body);
49 if let Some(credential) = &self.credential {
50 request = request.bearer_auth(credential);
51 }
52 let span = export::telemetry::http_client_span("POST", &url);
53 let response = export::telemetry::send(span, request)
54 .await
55 .context("sending runner request")?;
56 let status = response.status();
57 let text = response.text().await.unwrap_or_default();
58 if status != StatusCode::OK {
59 anyhow::bail!("runner request {path} failed: {status} {text}");
60 }
61 let envelope: serde_json::Value =
62 serde_json::from_str(&text).context("decoding response envelope")?;
63 let data = envelope
64 .get("data")
65 .cloned()
66 .ok_or_else(|| anyhow::anyhow!("response envelope has no data"))?;
67 serde_json::from_value(data).with_context(|| format!("decoding response data for {path}"))
68 }
69
70 pub async fn register(&self, request: &RegisterRequest) -> anyhow::Result<RegisterResponse> {
71 self.post("/register", request).await
72 }
73
74 pub async fn heartbeat(&self, request: &HeartbeatRequest) -> anyhow::Result<HeartbeatResponse> {
75 self.post("/heartbeat", request).await
76 }
77
78 pub async fn claim(&self, request: &ClaimRequest) -> anyhow::Result<ClaimResponse> {
79 self.post("/jobs/claim", request).await
80 }
81
82 pub async fn start(&self, job_id: &Uuid) -> anyhow::Result<Uuid> {
83 self.post(&format!("/jobs/{job_id}/start"), &serde_json::json!({}))
84 .await
85 }
86
87 pub async fn renew(&self, job_id: &Uuid) -> anyhow::Result<Uuid> {
88 self.post(&format!("/jobs/{job_id}/renew"), &serde_json::json!({}))
89 .await
90 }
91
92 pub async fn report_step(
93 &self,
94 job_id: Uuid,
95 index: i32,
96 report: &StepReport,
97 ) -> anyhow::Result<Uuid> {
98 self.post(&format!("/jobs/{job_id}/steps/{index}"), report)
99 .await
100 }
101
102 pub async fn upload_logs(
103 &self,
104 job_id: Uuid,
105 request: &LogChunksRequest,
106 ) -> anyhow::Result<Uuid> {
107 self.post(&format!("/jobs/{job_id}/logs"), request).await
108 }
109
110 pub async fn finish(&self, job_id: &Uuid, request: &JobFinishRequest) -> anyhow::Result<Uuid> {
111 self.post(&format!("/jobs/{job_id}/finish"), request).await
112 }
113
114 pub async fn event(
115 &self,
116 job_id: Uuid,
117 event: &ci_protocol::actions::EngineEvent,
118 ) -> anyhow::Result<Uuid> {
119 self.post(&format!("/jobs/{job_id}/events"), event).await
120 }
121
122 pub async fn init_artifact(
123 &self,
124 job_id: Uuid,
125 request: &ArtifactInitRequest,
126 ) -> anyhow::Result<ArtifactInitResponse> {
127 self.post(&format!("/jobs/{job_id}/artifacts/init"), request)
128 .await
129 }
130
131 pub async fn complete_artifact(
132 &self,
133 job_id: Uuid,
134 artifact_id: Uuid,
135 request: &ArtifactCompleteRequest,
136 ) -> anyhow::Result<Uuid> {
137 self.post(
138 &format!("/jobs/{job_id}/artifacts/{artifact_id}/complete"),
139 request,
140 )
141 .await
142 }
143}
Deleted app/runner/src/config.rs
@@ -1,11 +0,0 @@
1use anyhow::Context;
2
3pub fn ensure_parent(path: &std::path::Path) -> anyhow::Result<()> {
4 if let Some(parent) = path.parent()
5 && !parent.as_os_str().is_empty()
6 {
7 std::fs::create_dir_all(parent)
8 .with_context(|| format!("creating {}", parent.display()))?;
9 }
10 Ok(())
11}
Deleted app/runner/src/executor.rs
@@ -1,597 +0,0 @@
1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::process::Stdio;
4use std::sync::{Arc, Mutex, OnceLock};
5
6use ci_protocol::runner::{JobPayload, StepPayload};
7use config::runner::RunnerConfig;
8use tokio::io::{AsyncBufReadExt, BufReader};
9use tokio::process::Command;
10use tracing::Instrument as _;
11
12pub const MAX_STEP_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
13
14#[derive(Debug)]
15pub enum JobOutcome {
16 Success {
17 exit_code: Option<i32>,
18 },
19 Failure {
20 exit_code: Option<i32>,
21 error: Option<String>,
22 },
23 Cancelled,
24 TimedOut,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum StepSignal {
29 Started,
30 Finished,
31}
32
33static CANCELLED: OnceLock<Mutex<HashMap<uuid::Uuid, ()>>> = OnceLock::new();
34
35fn cancelled_set() -> &'static Mutex<HashMap<uuid::Uuid, ()>> {
36 CANCELLED.get_or_init(|| Mutex::new(HashMap::new()))
37}
38
39pub fn request_cancel(job_ids: &[uuid::Uuid]) {
40 if let Ok(mut set) = cancelled_set().lock() {
41 for id in job_ids {
42 set.insert(*id, ());
43 }
44 }
45 #[cfg(unix)]
46 {
47 if let Ok(pids) = running_pids().lock()
48 && let Some(pid) = job_ids.iter().find_map(|id| pids.get(id).copied())
49 {
50 let _ = nix_kill_group(pid);
51 }
52 }
53}
54
55pub fn clear_cancel(job_id: &uuid::Uuid) {
56 if let Ok(mut set) = cancelled_set().lock() {
57 set.remove(job_id);
58 }
59}
60
61pub(crate) fn is_cancelled(job_id: &uuid::Uuid) -> bool {
62 cancelled_set()
63 .lock()
64 .map(|set| set.contains_key(job_id))
65 .unwrap_or(false)
66}
67
68static RUNNING_PIDS: OnceLock<Mutex<HashMap<uuid::Uuid, i32>>> = OnceLock::new();
69
70fn running_pids() -> &'static Mutex<HashMap<uuid::Uuid, i32>> {
71 RUNNING_PIDS.get_or_init(|| Mutex::new(HashMap::new()))
72}
73
74#[cfg(unix)]
75fn nix_kill_group(pid: i32) {
76 // TERM the whole process group so shell children die with the step.
77 unsafe {
78 libc_kill(-pid, 15);
79 }
80}
81
82#[cfg(unix)]
83unsafe fn libc_kill(pid: i32, sig: i32) {
84 unsafe extern "C" {
85 fn kill(pid: i32, sig: i32) -> i32;
86 }
87 unsafe {
88 kill(pid, sig);
89 }
90}
91
92#[derive(Clone)]
93pub struct StepHooks {
94 pub on_status: std::sync::Arc<dyn Fn(i32, StepSignal, Option<i32>) + Send + Sync>,
95 pub on_log: std::sync::Arc<dyn Fn(String) + Send + Sync>,
96}
97
98#[allow(clippy::too_many_arguments)]
99pub async fn execute_job(
100 cfg: &RunnerConfig,
101 payload: &JobPayload,
102 hooks: StepHooks,
103 on_artifact: std::sync::Arc<dyn Fn(String, String, i64, Vec<u8>) + Send + Sync>,
104 renew: std::sync::Arc<dyn Fn() + Send + Sync>,
105) -> JobOutcome {
106 let workspace = PathBuf::from(&cfg.workspace_root)
107 .join(payload.run_id.to_string())
108 .join(payload.job_id.to_string());
109 if let Err(error) = std::fs::create_dir_all(&workspace) {
110 return JobOutcome::Failure {
111 exit_code: None,
112 error: Some(format!("creating workspace failed: {error}")),
113 };
114 }
115
116 let repo_dir = workspace.join("repo");
117 if let Err(error) = prepare_workspace(cfg, payload, &workspace, &repo_dir, hooks.clone()).await
118 {
119 cleanup(cfg, &workspace);
120 return JobOutcome::Failure {
121 exit_code: None,
122 error: Some(error.to_string()),
123 };
124 }
125
126 if let Err(error) = download_needs_artifacts(payload, &repo_dir, hooks.clone()).await {
127 (hooks.on_log)(format!("artifact download failed: {error}\n"));
128 }
129
130 let mut exit_code = 0i32;
131 let mut failure: Option<String> = None;
132 let deadline =
133 std::time::Instant::now() + std::time::Duration::from_secs(payload.timeout_secs.max(1));
134
135 for step in &payload.steps {
136 if is_cancelled(&payload.job_id) {
137 (hooks.on_status)(step.index, StepSignal::Finished, None);
138 cleanup(cfg, &workspace);
139 return JobOutcome::Cancelled;
140 }
141 if std::time::Instant::now() >= deadline {
142 cleanup(cfg, &workspace);
143 return JobOutcome::TimedOut;
144 }
145 (hooks.on_status)(step.index, StepSignal::Started, None);
146 (hooks.on_log)(format!(
147 "::group:: step {} · {}\n",
148 step.index + 1,
149 step.name
150 ));
151 let step_span = tracing::info_span!("ci.step", index = step.index, name = %step.name);
152 let result = run_step(cfg, payload, step, &repo_dir, hooks.clone(), deadline)
153 .instrument(step_span)
154 .await;
155 match result {
156 Ok(code) => {
157 (hooks.on_status)(step.index, StepSignal::Finished, Some(code));
158 (hooks.on_log)("::endgroup::\n".to_string());
159 if code != 0 {
160 exit_code = code;
161 failure = Some(format!("step `{}` exited with {code}", step.name));
162 break;
163 }
164 }
165 Err(StepFailure::Cancelled) => {
166 (hooks.on_status)(step.index, StepSignal::Finished, None);
167 cleanup(cfg, &workspace);
168 return JobOutcome::Cancelled;
169 }
170 Err(StepFailure::TimedOut) => {
171 (hooks.on_status)(step.index, StepSignal::Finished, None);
172 cleanup(cfg, &workspace);
173 return JobOutcome::TimedOut;
174 }
175 Err(StepFailure::Io(message)) => {
176 (hooks.on_status)(step.index, StepSignal::Finished, None);
177 (hooks.on_log)(format!("{message}\n"));
178 exit_code = 1;
179 failure = Some(message);
180 break;
181 }
182 }
183 (renew)();
184 }
185
186 collect_artifacts(cfg, payload, &repo_dir, Arc::clone(&on_artifact)).await;
187 cleanup(cfg, &workspace);
188
189 match failure {
190 None => JobOutcome::Success {
191 exit_code: Some(exit_code),
192 },
193 Some(error) => JobOutcome::Failure {
194 exit_code: Some(exit_code),
195 error: Some(error),
196 },
197 }
198}
199
200enum StepFailure {
201 Cancelled,
202 TimedOut,
203 Io(String),
204}
205
206async fn prepare_workspace(
207 cfg: &RunnerConfig,
208 payload: &JobPayload,
209 workspace: &Path,
210 repo_dir: &Path,
211 hooks: StepHooks,
212) -> anyhow::Result<()> {
213 (hooks.on_log)(format!(
214 "clone {} @ {}\n",
215 payload.clone_url, payload.commit_sha
216 ));
217 if repo_dir.exists() {
218 std::fs::remove_dir_all(repo_dir)?;
219 }
220 let auth_url = match &cfg.pat {
221 pat if !pat.trim().is_empty() => inject_pat(&payload.clone_url, pat),
222 _ => payload.clone_url.clone(),
223 };
224 let mut command = Command::new(&cfg.git_binary);
225 command
226 .arg("clone")
227 .arg("--no-tags")
228 .arg(&auth_url)
229 .arg(repo_dir)
230 .stdout(Stdio::piped())
231 .stderr(Stdio::piped())
232 .current_dir(workspace);
233 run_streaming(command, None, &cfg.clone_timeout_secs, hooks.clone()).await?;
234 let mut checkout = Command::new(&cfg.git_binary);
235 checkout
236 .arg("-C")
237 .arg(repo_dir)
238 .arg("checkout")
239 .arg("--force")
240 .arg(&payload.commit_sha)
241 .stdout(Stdio::piped())
242 .stderr(Stdio::piped());
243 run_streaming(checkout, None, &cfg.clone_timeout_secs, hooks.clone()).await?;
244 Ok(())
245}
246
247fn inject_pat(url: &str, pat: &str) -> String {
248 match url.split_once("://") {
249 Some((scheme, rest)) => format!("{scheme}://{pat}@{rest}"),
250 None => url.to_string(),
251 }
252}
253
254async fn download_needs_artifacts(
255 payload: &JobPayload,
256 repo_dir: &Path,
257 hooks: StepHooks,
258) -> anyhow::Result<()> {
259 for artifact in &payload.needs_artifacts {
260 let response = reqwest::get(&artifact.download_url).await?;
261 if !response.status().is_success() {
262 anyhow::bail!("artifact {} returned {}", artifact.name, response.status());
263 }
264 let bytes = response.bytes().await?;
265 if let Some(expected) = &artifact.sha256 {
266 let actual = sha256_hex(&bytes);
267 if &actual != expected {
268 anyhow::bail!("artifact {} checksum mismatch", artifact.name);
269 }
270 }
271 let dir = repo_dir.join(".gitdata-artifacts");
272 std::fs::create_dir_all(&dir)?;
273 std::fs::write(dir.join(&artifact.name), &bytes)?;
274 (hooks.on_log)(format!(
275 "downloaded artifact {} ({} bytes) into .gitdata-artifacts/\n",
276 artifact.name,
277 bytes.len()
278 ));
279 }
280 Ok(())
281}
282
283async fn run_step(
284 cfg: &RunnerConfig,
285 payload: &JobPayload,
286 step: &StepPayload,
287 repo_dir: &Path,
288 hooks: StepHooks,
289 job_deadline: std::time::Instant,
290) -> Result<i32, StepFailure> {
291 let workdir = match &step.working_directory {
292 Some(dir) if !dir.trim().is_empty() => repo_dir.join(dir.trim()),
293 _ => repo_dir.to_path_buf(),
294 };
295 if !workdir.starts_with(repo_dir) {
296 return Err(StepFailure::Io(
297 "working-directory escapes the workspace".to_string(),
298 ));
299 }
300 let shell = step
301 .shell
302 .clone()
303 .unwrap_or_else(|| cfg.default_shell.clone());
304 let mut env: HashMap<String, String> = payload.env.clone().into_iter().collect();
305 env.extend(step.env.clone());
306 // Secrets ride on the job payload and are layered under the workflow/step
307 // env so a stored secret cannot be shadowed by a plaintext variable.
308 for (key, value) in &payload.secrets {
309 env.insert(key.clone(), value.clone());
310 }
311 if payload.allow_git_token && !cfg.pat.trim().is_empty() {
312 env.insert("GITDATA_TOKEN".to_string(), cfg.pat.clone());
313 }
314
315 let timeout = step
316 .timeout_secs
317 .map(|secs| std::time::Duration::from_secs(secs.max(1)))
318 .unwrap_or_else(|| {
319 job_deadline
320 .saturating_duration_since(std::time::Instant::now())
321 .max(std::time::Duration::from_secs(1))
322 });
323
324 let mut command = if let Some(image) = &payload.container {
325 if !cfg.docker_enabled {
326 return Err(StepFailure::Io(
327 "job requires a container but docker is disabled on this runner".to_string(),
328 ));
329 }
330 let mut cmd = Command::new(&cfg.docker_binary);
331 cmd.arg("run")
332 .arg("--rm")
333 .arg("-v")
334 .arg(format!("{}:/workspace", repo_dir.display()))
335 .arg("-w")
336 .arg(match &step.working_directory {
337 Some(dir) if !dir.trim().is_empty() => {
338 format!("/workspace/{}", dir.trim().trim_start_matches('/'))
339 }
340 _ => "/workspace".to_string(),
341 });
342 for (key, value) in &env {
343 cmd.arg("-e").arg(format!("{key}={value}"));
344 }
345 cmd.arg(image).arg(&shell).arg("-c").arg(&step.run);
346 cmd
347 } else {
348 let mut cmd = Command::new(&shell);
349 cmd.arg("-c").arg(&step.run).current_dir(&workdir);
350 for (key, value) in &env {
351 cmd.env(key, value);
352 }
353 cmd
354 };
355
356 command
357 .stdout(Stdio::piped())
358 .stderr(Stdio::piped())
359 .stdin(Stdio::null());
360 #[cfg(unix)]
361 {
362 command.process_group(0);
363 }
364
365 let mut child = command
366 .spawn()
367 .map_err(|e| StepFailure::Io(format!("spawning step failed: {e}")))?;
368 let pid = child.id().map(|p| p as i32);
369 if let Some(pid) = pid
370 && let Ok(mut pids) = running_pids().lock()
371 {
372 pids.insert(payload.job_id, pid);
373 }
374
375 let stdout = child.stdout.take();
376 let stderr = child.stderr.take();
377 let stdout_hook = Arc::clone(&hooks.on_log);
378 let stdout_task = stdout.map(|stdout| {
379 tokio::spawn(async move {
380 let mut lines = BufReader::new(stdout).lines();
381 while let Ok(Some(line)) = lines.next_line().await {
382 stdout_hook(format!("{line}\n"));
383 }
384 })
385 });
386 let stderr_hook = Arc::clone(&hooks.on_log);
387 let stderr_task = stderr.map(|stderr| {
388 tokio::spawn(async move {
389 let mut lines = BufReader::new(stderr).lines();
390 while let Ok(Some(line)) = lines.next_line().await {
391 stderr_hook(format!("{line}\n"));
392 }
393 })
394 });
395 // Race the wait against a cancel poll so `docker run` / host steps stop
396 // promptly on every platform, not only where SIGTERM reaches a group.
397 let wait: Result<Result<std::process::ExitStatus, std::io::Error>, StepFailure> = tokio::select! {
398 result = tokio::time::timeout(timeout, child.wait()) => result.map_err(|_| StepFailure::TimedOut),
399 _ = async {
400 while !is_cancelled(&payload.job_id) {
401 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
402 }
403 } => {
404 let _ = child.kill().await;
405 let _ = child.wait().await;
406 Err(StepFailure::Cancelled)
407 }
408 };
409 if let Some(task) = stdout_task {
410 let _ = task.await;
411 }
412 if let Some(task) = stderr_task {
413 let _ = task.await;
414 }
415 if let Some(_pid) = pid
416 && let Ok(mut pids) = running_pids().lock()
417 {
418 pids.remove(&payload.job_id);
419 }
420
421 match wait {
422 Ok(Ok(status)) => {
423 if is_cancelled(&payload.job_id) {
424 return Err(StepFailure::Cancelled);
425 }
426 Ok(status.code().unwrap_or(-1))
427 }
428 Ok(Err(error)) => Err(StepFailure::Io(format!("step wait failed: {error}"))),
429 Err(StepFailure::Cancelled) => {
430 let _ = child.kill().await;
431 Err(StepFailure::Cancelled)
432 }
433 Err(StepFailure::TimedOut) => {
434 let _ = child.kill().await;
435 if is_cancelled(&payload.job_id) {
436 Err(StepFailure::Cancelled)
437 } else {
438 Err(StepFailure::TimedOut)
439 }
440 }
441 Err(StepFailure::Io(message)) => Err(StepFailure::Io(message)),
442 }
443}
444
445async fn run_streaming(
446 mut command: Command,
447 stdin: Option<Vec<u8>>,
448 timeout_secs: &u64,
449 hooks: StepHooks,
450) -> anyhow::Result<()> {
451 let _ = stdin;
452 let mut child = command.spawn()?;
453 let stdout = child.stdout.take();
454 let stderr = child.stderr.take();
455 let stdout_hook = Arc::clone(&hooks.on_log);
456 let stdout_task = stdout.map(|stdout| {
457 tokio::spawn(async move {
458 let mut lines = BufReader::new(stdout).lines();
459 while let Ok(Some(line)) = lines.next_line().await {
460 stdout_hook(format!("{line}\n"));
461 }
462 })
463 });
464 let stderr_hook = Arc::clone(&hooks.on_log);
465 let stderr_task = stderr.map(|stderr| {
466 tokio::spawn(async move {
467 let mut lines = BufReader::new(stderr).lines();
468 while let Ok(Some(line)) = lines.next_line().await {
469 stderr_hook(format!("{line}\n"));
470 }
471 })
472 });
473 let status = tokio::time::timeout(
474 std::time::Duration::from_secs((*timeout_secs).max(1)),
475 child.wait(),
476 )
477 .await;
478 if let Some(task) = stdout_task {
479 let _ = task.await;
480 }
481 if let Some(task) = stderr_task {
482 let _ = task.await;
483 }
484 match status {
485 Ok(status) => {
486 let status = status?;
487 if status.success() {
488 Ok(())
489 } else {
490 anyhow::bail!("command exited with {:?}", status.code())
491 }
492 }
493 Err(_) => {
494 let _ = child.kill().await;
495 anyhow::bail!("command timed out")
496 }
497 }
498}
499
500async fn collect_artifacts(
501 _cfg: &RunnerConfig,
502 payload: &JobPayload,
503 repo_dir: &Path,
504 on_artifact: std::sync::Arc<dyn Fn(String, String, i64, Vec<u8>) + Send + Sync>,
505) {
506 for spec in &payload.artifacts {
507 let base = repo_dir.join(&spec.path);
508 let mut files: Vec<(String, Vec<u8>)> = Vec::new();
509 if base.is_file() {
510 if let Ok(bytes) = std::fs::read(&base) {
511 files.push((spec.path.clone(), bytes));
512 }
513 } else if base.is_dir() {
514 collect_dir(&base, &spec.path, &mut files);
515 } else if let Ok(set) = globset::Glob::new(&spec.path) {
516 let matcher = set.compile_matcher();
517 walk(repo_dir, repo_dir, &mut |rel, path| {
518 if matcher.is_match(rel)
519 && let Ok(bytes) = std::fs::read(path)
520 {
521 files.push((rel.to_string(), bytes));
522 }
523 });
524 }
525 if files.is_empty() {
526 continue;
527 }
528 // A spec maps to a single artifact; directory/glob payloads are
529 // tar-less concatenations preserved per file under the artifact name.
530 if files.len() == 1 {
531 let (_, bytes) = files.remove(0);
532 let size = bytes.len() as i64;
533 on_artifact(spec.name.clone(), spec.path.clone(), size, bytes);
534 } else {
535 let mut bytes = Vec::new();
536 for (rel, content) in files {
537 bytes.extend_from_slice(format!("--- {rel} ---\n").as_bytes());
538 bytes.extend_from_slice(&content);
539 bytes.push(b'\n');
540 }
541 let size = bytes.len() as i64;
542 on_artifact(spec.name.clone(), spec.path.clone(), size, bytes);
543 }
544 }
545}
546
547fn collect_dir(dir: &Path, prefix: &str, out: &mut Vec<(String, Vec<u8>)>) {
548 walk(dir, dir, &mut |rel, path| {
549 if path.is_file()
550 && let Ok(bytes) = std::fs::read(path)
551 {
552 out.push((format!("{prefix}/{rel}"), bytes));
553 }
554 });
555}
556
557fn walk(root: &Path, dir: &Path, visit: &mut dyn FnMut(&str, &Path)) {
558 let Ok(entries) = std::fs::read_dir(dir) else {
559 return;
560 };
561 for entry in entries.flatten() {
562 let path = entry.path();
563 if path.is_dir() {
564 walk(root, &path, visit);
565 } else if let Ok(rel) = path.strip_prefix(root) {
566 visit(&rel.to_string_lossy(), &path);
567 }
568 }
569}
570
571fn cleanup(cfg: &RunnerConfig, workspace: &Path) {
572 if cfg.keep_workspace {
573 return;
574 }
575 let _ = std::fs::remove_dir_all(workspace);
576}
577
578pub async fn upload_file(url: &str, bytes: Vec<u8>) -> anyhow::Result<()> {
579 let span = export::telemetry::http_client_span("PUT", url);
580 let response =
581 export::telemetry::send(span, reqwest::Client::new().put(url).body(bytes)).await?;
582 if !response.status().is_success() {
583 anyhow::bail!("upload failed with {}", response.status());
584 }
585 Ok(())
586}
587
588#[must_use]
589pub fn sha256_hex(bytes: &[u8]) -> String {
590 use sha2::{Digest, Sha256};
591 let digest = Sha256::digest(bytes);
592 let mut out = String::with_capacity(digest.len() * 2);
593 for byte in digest {
594 out.push_str(&format!("{byte:02x}"));
595 }
596 out
597}
Deleted app/runner/src/lib.rs
@@ -1,5 +0,0 @@
1pub mod actions;
2pub mod client;
3pub mod config;
4pub mod executor;
5pub mod state;
Deleted app/runner/src/main.rs
@@ -1,394 +0,0 @@
1use anyhow::{Context, bail};
2use ci_protocol::runner::{
3 ArtifactCompleteRequest, ArtifactInitRequest, ClaimRequest, HeartbeatRequest, JobFinishRequest,
4 JobFinishState, JobPayload, LogChunk, LogChunksRequest, RegisterRequest, StepReport,
5 StepReportState,
6};
7use runner::client::RunnerClient;
8use runner::executor::{JobOutcome, StepHooks, execute_job};
9use runner::state::{credential_path, load_credential, store_credential};
10use tracing::{Instrument as _, info, warn};
11
12#[tokio::main]
13async fn main() -> anyhow::Result<()> {
14 init_tracing();
15
16 let mut cfg = config::runner::RunnerConfig::load().context("loading runner configuration")?;
17 for (label, platform) in &cfg.actions_platforms {
18 if (cfg.docker_enabled || platform == "-self-hosted") && !cfg.labels.contains(label) {
19 cfg.labels.push(label.clone());
20 }
21 }
22 if cfg.registration_token.trim().is_empty() && !credential_path(&cfg).exists() {
23 bail!("runner needs APP_RUNNER__REGISTRATION_TOKEN or an existing credential file");
24 }
25
26 std::fs::create_dir_all(&cfg.workspace_root)
27 .with_context(|| format!("creating workspace root {}", cfg.workspace_root))?;
28
29 let client = RunnerClient::new(&cfg)?;
30 let actions_available = runner::actions::available(&cfg);
31 let protocol_versions = if actions_available {
32 vec![2, 1]
33 } else {
34 vec![1]
35 };
36 let mut capabilities: Vec<String> = if actions_available {
37 vec!["actions-v2".into()]
38 } else {
39 Vec::new()
40 };
41 if cfg.docker_enabled {
42 capabilities.push("docker".into());
43 }
44 capabilities.extend(
45 cfg.actions_groups
46 .iter()
47 .map(|group| format!("runner-group:{group}")),
48 );
49 let credential = match load_credential(&cfg)? {
50 Some(credential) => credential,
51 None => {
52 info!(server = %cfg.server_url, "registering runner");
53 let response = client
54 .register(&RegisterRequest {
55 token: cfg.registration_token.clone(),
56 name: cfg.resolved_name(),
57 labels: cfg.labels.clone(),
58 capacity: cfg.capacity,
59 version: Some(env!("CARGO_PKG_VERSION").to_string()),
60 protocol_versions: protocol_versions.clone(),
61 capabilities: capabilities.clone(),
62 })
63 .await
64 .context("registering runner")?;
65 if !protocol_versions.contains(&response.protocol_version) {
66 bail!(
67 "runner protocol mismatch: server {} / runner {}",
68 response.protocol_version,
69 ci_protocol::PROTOCOL_VERSION
70 );
71 }
72 store_credential(&cfg, &response.credential)?;
73 info!(runner_id = %response.runner_id, "runner registered");
74 response.credential
75 }
76 };
77 let client = client.with_credential(credential);
78
79 let active = std::sync::Arc::new(std::sync::Mutex::new(
80 std::collections::HashSet::<uuid::Uuid>::new(),
81 ));
82 let heartbeat_client = client.clone();
83 let heartbeat_active = active.clone();
84 let heartbeat_interval = cfg.heartbeat_interval_secs.max(1);
85 let heartbeat_task = tokio::spawn(async move {
86 let mut ticker = tokio::time::interval(std::time::Duration::from_secs(heartbeat_interval));
87 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
88 loop {
89 ticker.tick().await;
90 let running: Vec<uuid::Uuid> = heartbeat_active
91 .lock()
92 .map(|set| set.iter().copied().collect())
93 .unwrap_or_default();
94 match heartbeat_client
95 .heartbeat(&HeartbeatRequest {
96 running_job_ids: running,
97 protocol_versions: protocol_versions.clone(),
98 capabilities: capabilities.clone(),
99 })
100 .await
101 {
102 Ok(response) => {
103 if !response.cancel.is_empty() {
104 warn!(jobs = ?response.cancel, "server requested job cancellation");
105 runner::executor::request_cancel(&response.cancel);
106 }
107 }
108 Err(error) => warn!(%error, "heartbeat failed"),
109 }
110 }
111 });
112
113 info!(server = %cfg.server_url, "runner polling for jobs");
114 let mut jobs = tokio::task::JoinSet::new();
115 loop {
116 while jobs.len() >= cfg.capacity.max(1) as usize {
117 if let Some(result) = jobs.join_next().await
118 && let Err(error) = result
119 {
120 warn!(%error,"runner job task failed");
121 }
122 }
123 let payload = match client.claim(&ClaimRequest { wait_secs: None }).await {
124 Ok(response) => match response.job {
125 Some(job) => job,
126 None => continue,
127 },
128 Err(error) => {
129 warn!(%error, "claim failed; backing off");
130 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
131 continue;
132 }
133 };
134 info!(job_id = %payload.job_id, job = %payload.job_name, "job received");
135 if let Ok(mut set) = active.lock() {
136 set.insert(payload.job_id);
137 }
138 let job_client = client.clone();
139 let job_config = cfg.clone();
140 let job_active = active.clone();
141 jobs.spawn(async move {
142 execute_claimed_job(&job_client, &job_config, &payload).await;
143 if let Ok(mut set) = job_active.lock() {
144 set.remove(&payload.job_id);
145 }
146 runner::executor::clear_cancel(&payload.job_id);
147 });
148 if cfg.once {
149 info!("runner configured for a single job; exiting");
150 break;
151 }
152 }
153 while let Some(result) = jobs.join_next().await {
154 if let Err(error) = result {
155 warn!(%error,"runner job task failed");
156 }
157 }
158
159 heartbeat_task.abort();
160 export::telemetry::shutdown_telemetry();
161 Ok(())
162}
163
164async fn execute_claimed_job(
165 client: &RunnerClient,
166 cfg: &config::runner::RunnerConfig,
167 payload: &JobPayload,
168) {
169 let span = tracing::info_span!(
170 "ci.job",
171 job_id = %payload.job_id,
172 run_id = %payload.run_id,
173 job = %payload.job_name,
174 request_id = tracing::field::Empty
175 );
176 if let Some(request_id) = payload.request_id.as_deref() {
177 span.record("request_id", request_id);
178 }
179 export::telemetry::set_parent_from_traceparent(&span, payload.trace.as_deref());
180 export::middleware::with_request_id(
181 payload.request_id.as_deref(),
182 run_claimed_job(client, cfg, payload).instrument(span),
183 )
184 .await;
185}
186
187async fn run_claimed_job(
188 client: &RunnerClient,
189 cfg: &config::runner::RunnerConfig,
190 payload: &JobPayload,
191) {
192 let finish = if payload.actions.is_some() {
193 runner::actions::execute(client, cfg, payload).await
194 } else {
195 match run_job(client, cfg, payload).await {
196 JobOutcome::Success { exit_code } => JobFinishRequest {
197 state: JobFinishState::Success,
198 exit_code,
199 ..Default::default()
200 },
201 JobOutcome::Failure { exit_code, error } => JobFinishRequest {
202 state: JobFinishState::Failure,
203 exit_code,
204 error,
205 ..Default::default()
206 },
207 JobOutcome::Cancelled => JobFinishRequest {
208 state: JobFinishState::Cancelled,
209 error: Some("cancelled".into()),
210 ..Default::default()
211 },
212 JobOutcome::TimedOut => JobFinishRequest {
213 state: JobFinishState::TimedOut,
214 error: Some("job timed out".into()),
215 ..Default::default()
216 },
217 }
218 };
219 for attempt in 0..3 {
220 match client.finish(&payload.job_id, &finish).await {
221 Ok(_) => return,
222 Err(error) => {
223 warn!(%error,attempt,"job finish report failed");
224 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
225 }
226 }
227 }
228}
229
230async fn run_job(
231 client: &RunnerClient,
232 cfg: &config::runner::RunnerConfig,
233 payload: &JobPayload,
234) -> JobOutcome {
235 if let Err(error) = client.start(&payload.job_id).await {
236 warn!(%error, "job start report failed");
237 }
238
239 let request_id = export::telemetry::request_id();
240 let seq = std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0));
241 let log_client = client.clone();
242 let log_job = payload.job_id;
243 // Mask secret values runner-side before they ever leave the machine; the
244 // scheduler redacts again on ingest.
245 let log_secrets: Vec<String> = payload.secrets.values().cloned().collect();
246 let on_log = {
247 let seq = std::sync::Arc::clone(&seq);
248 let secrets = log_secrets.clone();
249 let log_request_id = request_id.clone();
250 move |text: String| {
251 let client = log_client.clone();
252 let job_id = log_job;
253 let text = mask_secrets(text, &secrets);
254 let num = seq.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
255 let request_id = log_request_id.clone();
256 tokio::spawn(async move {
257 let _ = export::middleware::with_request_id(
258 request_id.as_deref(),
259 client.upload_logs(
260 job_id,
261 &LogChunksRequest {
262 chunks: vec![LogChunk { seq: num, text }],
263 },
264 ),
265 )
266 .await;
267 });
268 }
269 };
270
271 let step_client = client.clone();
272 let step_job = payload.job_id;
273 let step_request_id = request_id.clone();
274 let on_status =
275 move |index: i32, signal: runner::executor::StepSignal, exit_code: Option<i32>| {
276 let client = step_client.clone();
277 let job_id = step_job;
278 let request_id = step_request_id.clone();
279 tokio::spawn(async move {
280 let report = StepReport {
281 state: match signal {
282 runner::executor::StepSignal::Started => StepReportState::Started,
283 runner::executor::StepSignal::Finished => StepReportState::Finished,
284 },
285 exit_code,
286 name: None,
287 shell: None,
288 working_directory: None,
289 script: None,
290 timeout_secs: None,
291 started_at: (signal == runner::executor::StepSignal::Started)
292 .then(time::OffsetDateTime::now_utc),
293 finished_at: (signal == runner::executor::StepSignal::Finished)
294 .then(time::OffsetDateTime::now_utc),
295 ..Default::default()
296 };
297 let _ = export::middleware::with_request_id(
298 request_id.as_deref(),
299 client.report_step(job_id, index, &report),
300 )
301 .await;
302 });
303 };
304
305 let artifact_client = client.clone();
306 let artifact_job = payload.job_id;
307 let artifact_request_id = request_id.clone();
308 let on_artifact = move |name: String, path: String, size: i64, bytes: Vec<u8>| {
309 let client = artifact_client.clone();
310 let job_id = artifact_job;
311 let request_id = artifact_request_id.clone();
312 tokio::spawn(async move {
313 export::middleware::with_request_id(request_id.as_deref(), async move {
314 let sha256 = runner::executor::sha256_hex(&bytes);
315 let init = match client
316 .init_artifact(
317 job_id,
318 &ArtifactInitRequest {
319 name: name.clone(),
320 size,
321 sha256: Some(sha256.clone()),
322 },
323 )
324 .await
325 {
326 Ok(init) => init,
327 Err(error) => {
328 warn!(%error, %name, "artifact init failed");
329 return;
330 }
331 };
332 if let Err(error) = runner::executor::upload_file(&init.upload_url, bytes).await {
333 warn!(%error, %name, "artifact upload failed");
334 return;
335 }
336 let _ = path;
337 if let Err(error) = client
338 .complete_artifact(
339 job_id,
340 init.artifact_id,
341 &ArtifactCompleteRequest {
342 size,
343 sha256: Some(sha256),
344 },
345 )
346 .await
347 {
348 warn!(%error, %name, "artifact completion failed");
349 }
350 })
351 .await;
352 });
353 };
354
355 let renew_client = client.clone();
356 let renew_job = payload.job_id;
357 let renew_request_id = request_id;
358 let renew = move || {
359 let client = renew_client.clone();
360 let job_id = renew_job;
361 let request_id = renew_request_id.clone();
362 tokio::spawn(async move {
363 let _ =
364 export::middleware::with_request_id(request_id.as_deref(), client.renew(&job_id))
365 .await;
366 });
367 };
368
369 let hooks = StepHooks {
370 on_status: std::sync::Arc::new(on_status),
371 on_log: std::sync::Arc::new(on_log),
372 };
373 execute_job(
374 cfg,
375 payload,
376 hooks,
377 std::sync::Arc::new(on_artifact),
378 std::sync::Arc::new(renew),
379 )
380 .await
381}
382
383fn mask_secrets(mut text: String, secrets: &[String]) -> String {
384 for secret in secrets {
385 if secret.len() >= 4 && text.contains(secret.as_str()) {
386 text = text.replace(secret.as_str(), "***");
387 }
388 }
389 text
390}
391
392fn init_tracing() {
393 export::telemetry::init_tracing();
394}
Deleted app/runner/src/state.rs
@@ -1,45 +0,0 @@
1use anyhow::Context;
2use config::runner::RunnerConfig;
3use std::path::PathBuf;
4
5use crate::config::ensure_parent;
6
7pub fn credential_path(cfg: &RunnerConfig) -> PathBuf {
8 PathBuf::from(&cfg.credential_path)
9}
10
11pub fn load_credential(cfg: &RunnerConfig) -> anyhow::Result<Option<String>> {
12 let path = credential_path(cfg);
13 match std::fs::read_to_string(&path) {
14 Ok(text) => {
15 let trimmed = text.trim().to_string();
16 if trimmed.is_empty() {
17 return Ok(None);
18 }
19 Ok(Some(trimmed))
20 }
21 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
22 Err(error) => Err(error).with_context(|| format!("reading {}", path.display())),
23 }
24}
25
26pub fn store_credential(cfg: &RunnerConfig, credential: &str) -> anyhow::Result<()> {
27 let path = credential_path(cfg);
28 ensure_parent(&path)?;
29 std::fs::write(&path, credential).with_context(|| format!("writing {}", path.display()))?;
30 restrict_permissions(&path)?;
31 Ok(())
32}
33
34#[cfg(unix)]
35fn restrict_permissions(path: &std::path::Path) -> anyhow::Result<()> {
36 use std::os::unix::fs::PermissionsExt;
37 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
38 .with_context(|| format!("setting permissions on {}", path.display()))?;
39 Ok(())
40}
41
42#[cfg(not(unix))]
43fn restrict_permissions(_path: &std::path::Path) -> anyhow::Result<()> {
44 Ok(())
45}
Deleted app/runner/Cargo.toml
@@ -1,33 +0,0 @@
1[package]
2name = "runner"
3version.workspace = true
4edition.workspace = true
5publish = false
6
7[dependencies]
8ci-protocol = { workspace = true }
9config = { workspace = true }
10export = { workspace = true }
11serde = { workspace = true, features = ["derive"] }
12serde_json = { workspace = true }
13tokio = { workspace = true, features = ["signal", "macros", "rt-multi-thread", "time", "sync", "process", "fs"] }
14tracing = { workspace = true }
15tracing-subscriber = { workspace = true }
16anyhow = { workspace = true }
17reqwest = { workspace = true }
18uuid = { workspace = true }
19time = { workspace = true }
20sha2 = { workspace = true }
21base64 = { workspace = true }
22globset = { workspace = true }
23figment = { workspace = true }
24dotenvy = { workspace = true }
25
26
27[lib]
28name = "runner"
29path = "src/lib.rs"
30
31[[bin]]
32name = "runner"
33path = "src/main.rs"
Deleted app/searchhost/src/main.rs
@@ -1,48 +0,0 @@
1use std::path::PathBuf;
2use std::sync::Arc;
3
4use anyhow::Context;
5use config::scoped::{ScopedConfig, SynchostConfig};
6use search::{SearchService, SearchWorker};
7use tokio_util::sync::CancellationToken;
8use tracing::info;
9
10#[tokio::main]
11async fn main() -> anyhow::Result<()> {
12 export::telemetry::init_tracing();
13
14 let cfg = SynchostConfig::load().context("loading configuration")?;
15
16 info!("connecting to database");
17 let db = Arc::new(
18 export::AppDatabase::connect(cfg.database.clone())
19 .await
20 .context("connecting to postgres")?,
21 );
22
23 let search = SearchService::from_parts(db.clone(), cfg.search.clone())
24 .map_err(|e| anyhow::anyhow!("building search service: {e}"))?;
25 if !search.enabled() {
26 info!("search is disabled by config; exiting");
27 return Ok(());
28 }
29
30 let cancel = CancellationToken::new();
31 let workers = cfg.search.workers.max(1);
32 for index in 0..workers {
33 let worker = SearchWorker::new(
34 db.clone(),
35 search.clone(),
36 PathBuf::from(cfg.git.repo_root.clone()),
37 cancel.clone(),
38 );
39 tokio::spawn(worker.run());
40 info!(index, "search index worker started");
41 }
42 info!(workers, "searchhost running");
43
44 export::telemetry::shutdown_signal().await;
45 cancel.cancel();
46 info!("searchhost shut down");
47 Ok(())
48}
Deleted app/searchhost/Cargo.toml
@@ -1,14 +0,0 @@
1[package]
2name = "searchhost"
3version.workspace = true
4edition.workspace = true
5publish = false
6
7[dependencies]
8config = { workspace = true }
9export = { workspace = true }
10search = { workspace = true }
11tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
12tokio-util = { workspace = true }
13tracing = { workspace = true }
14anyhow = { workspace = true }
No newline at end of file
Deleted app/synchost/src/main.rs
@@ -1,106 +0,0 @@
1use std::net::SocketAddr;
2use std::path::PathBuf;
3use std::sync::Arc;
4use std::time::Duration;
5
6use anyhow::Context;
7use config::scoped::{ScopedConfig, SynchostConfig};
8use import::ImportWorker;
9use kenshell::KenShellClient;
10use search::{SearchService, SearchWorker};
11use tokio::net::TcpListener;
12use tokio_util::sync::CancellationToken;
13use tracing::info;
14
15#[tokio::main]
16async fn main() -> anyhow::Result<()> {
17 export::telemetry::init_tracing();
18
19 let cfg = SynchostConfig::load().context("loading configuration")?;
20
21 info!("connecting to database / redis / cache");
22 let db = Arc::new(
23 export::AppDatabase::connect(cfg.database.clone())
24 .await
25 .context("connecting to postgres")?,
26 );
27 let redis = export::AppRedis::connect(cfg.redis.clone())
28 .await
29 .context("connecting to redis")?;
30 let ks = KenShellClient::connect_with_key(
31 &cfg.git.kenshell_endpoint,
32 &cfg.git.kenshell_rpc_key,
33 Duration::from_secs(cfg.git.kenshell_timeout_secs.max(1)),
34 )
35 .context("building KenShell client")?;
36 info!("dependencies ready");
37
38 if let Err(error) = export::telemetry::init_meter_provider() {
39 tracing::warn!(%error, "failed to init meter provider; metrics disabled");
40 }
41 export::metrics::spawn_queue_depth_reporter(
42 redis.clone(),
43 &[
44 ("git.sync", cfg.git.sync.queue_topic.clone()),
45 ("git.sync.dlq", cfg.git.sync.dlq_topic.clone()),
46 ],
47 );
48
49 let cancel = CancellationToken::new();
50
51 let search = SearchService::from_parts(db.clone(), cfg.search.clone())
52 .map_err(|e| anyhow::anyhow!("building search service: {e}"))?;
53 if search.enabled() {
54 let workers = cfg.search.workers.max(1);
55 for index in 0..workers {
56 let worker = SearchWorker::new(
57 db.clone(),
58 search.clone(),
59 PathBuf::from(cfg.git.repo_root.clone()),
60 cancel.clone(),
61 );
62 tokio::spawn(worker.run());
63 info!(index, "search index worker started");
64 }
65 }
66
67 if cfg.import.enabled {
68 let worker = ImportWorker::new(
69 db.clone(),
70 redis.clone(),
71 ks.clone(),
72 cfg.import.clone(),
73 cancel.clone(),
74 );
75 tokio::spawn(worker.run());
76 info!("import worker started");
77 }
78
79 if cfg.health.enabled() {
80 let health_addr: SocketAddr =
81 format!("{}:{}", cfg.health.bind_addr(), cfg.health.synchost_port())
82 .parse()
83 .context("parsing health bind address")?;
84 let health_listener = TcpListener::bind(health_addr)
85 .await
86 .with_context(|| format!("binding health {health_addr}"))?;
87 info!(%health_addr, "synchost health listening");
88 let health_router = export::health::router_with(Some(db.clone()), redis.clone(), None);
89 let health_cancel = cancel.clone();
90 tokio::spawn(async move {
91 let _ = axum::serve(health_listener, health_router)
92 .with_graceful_shutdown(async move { health_cancel.cancelled().await })
93 .await;
94 });
95 }
96
97 info!("synchost running");
98
99 export::telemetry::shutdown_signal().await;
100 info!("synchost shutting down");
101
102 cancel.cancel();
103
104 info!("synchost shut down");
105 Ok(())
106}
Deleted app/synchost/Cargo.toml
@@ -1,20 +0,0 @@
1[package]
2name = "synchost"
3version.workspace = true
4edition.workspace = true
5publish = false
6
7[dependencies]
8git = { workspace = true }
9kenshell = { workspace = true }
10export = { workspace = true }
11config = { workspace = true }
12cache = { workspace = true }
13import = { workspace = true }
14search = { workspace = true }
15axum = { workspace = true }
16tokio = { workspace = true, features = ["signal", "net", "rt-multi-thread", "macros"] }
17tokio-util = { workspace = true }
18tracing = { workspace = true }
19tracing-subscriber = { workspace = true }
20anyhow = { workspace = true }
Deleted app/webhook/src/main.rs
@@ -1,256 +0,0 @@
1use std::net::SocketAddr;
2use std::time::Duration;
3
4use anyhow::Context;
5use axum::Router;
6use ci::CiService;
7use config::scoped::{ScopedConfig, WebhookServiceConfig};
8use tokio::net::TcpListener;
9use tokio_util::sync::CancellationToken;
10use tracing::Instrument as _;
11use tracing::{info, warn};
12
13#[tokio::main]
14async fn main() -> anyhow::Result<()> {
15 export::telemetry::init_tracing();
16
17 let cfg = WebhookServiceConfig::load().context("loading configuration")?;
18 if !cfg.webhook.enabled() {
19 info!("webhook is disabled by config; exiting");
20 return Ok(());
21 }
22 info!("connecting to database / redis / storage");
23 let state = export::AppState::with_config(cfg.clone().into_app_config())
24 .await
25 .context("building AppState")?;
26 let service = CiService::new(state);
27 info!("dependencies ready");
28
29 if let Err(err) = export::telemetry::init_meter_provider() {
30 warn!(error = %err, "failed to init meter provider; metrics disabled");
31 }
32 export::metrics::spawn_queue_depth_reporter(
33 service.state.redis.clone(),
34 &[
35 ("ci.events", cfg.ci.event_queue_topic.clone()),
36 ("ci.events.dlq", cfg.ci.event_dlq_topic.clone()),
37 ],
38 );
39
40 let cancel = CancellationToken::new();
41
42 let consumer = if cfg.ci.enabled {
43 Some(tokio::spawn(consume_events(
44 service.clone(),
45 cancel.clone(),
46 )))
47 } else {
48 info!("ci is disabled by config; scheduler not started");
49 None
50 };
51 let scheduler = if cfg.ci.enabled {
52 Some(tokio::spawn(run_scheduler(
53 service.clone(),
54 cfg.ci.scheduler_interval_secs,
55 cancel.clone(),
56 )))
57 } else {
58 None
59 };
60 let outbound = tokio::spawn(run_outbound(
61 service.clone(),
62 cfg.ci.scheduler_interval_secs,
63 cancel.clone(),
64 ));
65
66 let addr = SocketAddr::new(
67 cfg.webhook
68 .bind_addr()
69 .parse()
70 .context("webhook bind addr")?,
71 cfg.webhook.port(),
72 );
73 let listener = TcpListener::bind(addr)
74 .await
75 .with_context(|| format!("binding webhook {addr}"))?;
76 let runner_routes = ci::routes::runner_router().with_state(service.clone());
77 let router: Router = Router::new()
78 .nest("/api/v1", runner_routes)
79 .merge(ci::actions_runtime::router().with_state(service.clone()))
80 .merge(export::health::router_with(
81 Some(service.state.database.clone()),
82 service.state.redis.clone(),
83 service.state.kenauth.clone(),
84 ))
85 .layer(export::telemetry::http_trace_layer())
86 .layer(export::telemetry::metrics_layer())
87 .layer(export::middleware::AppTraceLayer::new());
88 info!(%addr, "webhook scheduler listening");
89
90 let server_cancel = cancel.clone();
91 axum::serve(listener, router.into_make_service())
92 .with_graceful_shutdown(async move { server_cancel.cancelled().await })
93 .await
94 .context("webhook server error")?;
95
96 cancel.cancel();
97 if let Some(consumer) = consumer {
98 let _ = consumer.await;
99 }
100 if let Some(scheduler) = scheduler {
101 let _ = scheduler.await;
102 }
103 let _ = outbound.await;
104 info!("webhook shut down");
105 Ok(())
106}
107
108async fn consume_events(service: CiService, cancel: CancellationToken) {
109 use ci::events::{decode_event, publish_event_to_dlq};
110 use git::sync::queue::SyncQueue;
111
112 let topic = service.config().ci.event_queue_topic.clone();
113 let dlq = service.config().ci.event_dlq_topic.clone();
114 // BRPOP blocks longer than the Redis request timeout, so the consumer
115 // needs its own dedicated connection (same as the sync worker).
116 let queue = match SyncQueue::with_consumer_conns(service.state.redis.clone(), 1).await {
117 Ok(queue) => queue,
118 Err(error) => {
119 warn!(%error, "failed to open ci event consumer connection");
120 return;
121 }
122 };
123 let mut attempts: std::collections::HashMap<uuid::Uuid, u32> = std::collections::HashMap::new();
124 loop {
125 let payload = tokio::select! {
126 _ = cancel.cancelled() => break,
127 res = queue.consume(&topic, 5, 0) => match res {
128 Ok(Some(payload)) => payload,
129 Ok(None) => continue,
130 Err(error) => {
131 warn!(%error, "event consume failed");
132 tokio::time::sleep(Duration::from_secs(2)).await;
133 continue;
134 }
135 },
136 };
137 let event = match decode_event(&payload) {
138 Ok(event) => event,
139 Err(error) => {
140 warn!(%error, "dropping malformed ci event");
141 let _ = publish_event_to_dlq(&service.state.redis, &dlq, &payload).await;
142 continue;
143 }
144 };
145 let event_kind = match &event {
146 ci_protocol::events::CiEvent::Push(_) => "push",
147 ci_protocol::events::CiEvent::PullRequest(_) => "pull_request",
148 ci_protocol::events::CiEvent::Trigger(_) => "trigger",
149 };
150 export::metrics::ci_event(event_kind);
151 let event_id = match &event {
152 ci_protocol::events::CiEvent::Push(push) => push.id,
153 ci_protocol::events::CiEvent::PullRequest(pr) => pr.id,
154 ci_protocol::events::CiEvent::Trigger(event) => event.id,
155 };
156 let span = tracing::info_span!(
157 "ci.event",
158 event = %event_id,
159 request_id = tracing::field::Empty
160 );
161 if let Some(request_id) = event.request_id() {
162 span.record("request_id", request_id);
163 }
164 export::telemetry::set_parent_from_traceparent(&span, event.trace());
165 match export::middleware::with_request_id(
166 event.request_id(),
167 service.handle_event(&event).instrument(span),
168 )
169 .await
170 {
171 Ok(created) => {
172 attempts.remove(&event_id);
173 export::metrics::ci_runs_created(event_kind, created.len());
174 if !created.is_empty() {
175 info!(event = %event_id, runs = created.len(), "ci event produced runs");
176 }
177 }
178 Err(error) => {
179 let count = attempts.entry(event_id).or_insert(0);
180 *count += 1;
181 if *count > 5 {
182 attempts.remove(&event_id);
183 warn!(%error, event = %event_id, "ci event failed permanently; sending to DLQ");
184 let _ = publish_event_to_dlq(&service.state.redis, &dlq, &payload).await;
185 } else {
186 warn!(%error, event = %event_id, attempt = *count, "ci event failed; retrying");
187 tokio::time::sleep(Duration::from_secs(2 * u64::from(*count))).await;
188 let _ = queue.publish_raw(&topic, None, &payload).await;
189 }
190 }
191 }
192 }
193 info!("event consumer stopped");
194}
195
196async fn run_outbound(service: CiService, interval_secs: u64, cancel: CancellationToken) {
197 let interval = Duration::from_secs(interval_secs.clamp(1, 30));
198 let mut ticker = tokio::time::interval(interval);
199 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
200 loop {
201 tokio::select! {
202 _ = cancel.cancelled() => break,
203 _ = ticker.tick() => {}
204 }
205 if let Err(error) = service.deliver_pending_repo_webhooks().await {
206 warn!(%error, "outbound webhook delivery failed");
207 }
208 }
209 info!("outbound webhook loop stopped");
210}
211
212async fn run_scheduler(service: CiService, interval_secs: u64, cancel: CancellationToken) {
213 if let Err(error) = service.discover_actions_schedules().await {
214 warn!(%error,"initial schedule discovery failed");
215 }
216 let interval = Duration::from_secs(interval_secs.max(1));
217 let mut ticker = tokio::time::interval(interval);
218 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
219 let mut ticks: u64 = 0;
220 loop {
221 tokio::select! {
222 _ = cancel.cancelled() => break,
223 _ = ticker.tick() => {}
224 }
225 if let Err(error) = service.advance_jobs().await {
226 warn!(%error, "advance jobs failed");
227 }
228 if let Err(error) = service.enforce_concurrency().await {
229 warn!(%error, "concurrency enforcement failed");
230 }
231 if let Err(error) = service.expire_leases().await {
232 warn!(%error, "lease expiry failed");
233 }
234 if let Err(error) = service.apply_cancellations().await {
235 warn!(%error, "cancellation sweep failed");
236 }
237 if let Err(error) = service.finalize_runs().await {
238 warn!(%error, "run finalization failed");
239 }
240 ticks = ticks.wrapping_add(1);
241 if let Err(error) = service.run_due_actions_schedules().await {
242 warn!(%error,"scheduled workflow execution failed");
243 }
244 if ticks.is_multiple_of(3600)
245 && let Err(error) = service.prune_expired().await
246 {
247 warn!(%error, "retention prune failed");
248 }
249 if ticks.is_multiple_of(30)
250 && let Err(error) = service.reap_stale_runners().await
251 {
252 warn!(%error, "stale runner sweep failed");
253 }
254 }
255 info!("scheduler stopped");
256}
Deleted app/webhook/Cargo.toml
@@ -1,26 +0,0 @@
1[package]
2name = "webhook"
3version.workspace = true
4edition.workspace = true
5publish = false
6
7[dependencies]
8ci = { workspace = true }
9ci-protocol = { workspace = true }
10config = { workspace = true }
11export = { workspace = true }
12git = { workspace = true }
13models = { workspace = true }
14storage = { workspace = true }
15axum = { workspace = true }
16sea-orm = { workspace = true }
17serde = { workspace = true, features = ["derive"] }
18serde_json = { workspace = true }
19tokio = { workspace = true, features = ["signal", "net", "macros", "rt-multi-thread", "sync", "time"] }
20tokio-util = { workspace = true }
21tracing = { workspace = true }
22tracing-subscriber = { workspace = true }
23anyhow = { workspace = true }
24uuid = { workspace = true }
25time = { workspace = true }
26reqwest = { workspace = true }
Deleted charts/act/templates/_helpers.tpl
@@ -1,66 +0,0 @@
1{{- define "runner.name" -}}
2{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
3{{- end -}}
4
5{{- define "runner.fullname" -}}
6{{- if .Values.fullnameOverride -}}
7{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
8{{- else -}}
9{{- $name := default .Chart.Name .Values.nameOverride -}}
10{{- if contains $name .Release.Name -}}
11{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
12{{- else -}}
13{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
14{{- end -}}
15{{- end -}}
16{{- end -}}
17
18{{- define "runner.chart" -}}
19{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
20{{- end -}}
21
22{{- define "runner.labels" -}}
23helm.sh/chart: {{ include "runner.chart" . }}
24app.kubernetes.io/name: {{ include "runner.name" . }}
25app.kubernetes.io/instance: {{ .Release.Name }}
26app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
27app.kubernetes.io/managed-by: {{ .Release.Service }}
28app.kubernetes.io/component: runner
29{{- end -}}
30
31{{- define "runner.selectorLabels" -}}
32app.kubernetes.io/name: {{ include "runner.name" . }}
33app.kubernetes.io/instance: {{ .Release.Name }}
34app.kubernetes.io/component: runner
35{{- end -}}
36
37{{- define "runner.serviceAccountName" -}}
38{{- if .Values.serviceAccount.create -}}
39{{- default (include "runner.fullname" .) .Values.serviceAccount.name -}}
40{{- else -}}
41{{- default "default" .Values.serviceAccount.name -}}
42{{- end -}}
43{{- end -}}
44
45{{- define "runner.image" -}}
46{{- printf "%s:%s" .Values.image.repository (default .Chart.AppVersion .Values.image.tag) -}}
47{{- end -}}
48
49{{- define "runner.actionsImage" -}}
50{{- printf "%s:%s" .Values.actionsImage.repository (default .Chart.AppVersion .Values.actionsImage.tag) -}}
51{{- end -}}
52
53{{- define "runner.secretName" -}}
54{{- if .Values.secret.existingSecret -}}
55{{- .Values.secret.existingSecret -}}
56{{- else -}}
57{{- printf "%s-env" (include "runner.fullname" .) -}}
58{{- end -}}
59{{- end -}}
60
61{{- define "runner.imagePullSecrets" -}}
62{{- with .Values.imagePullSecrets }}
63imagePullSecrets:
64 {{- toYaml . | nindent 2 }}
65{{- end }}
66{{- end -}}
No newline at end of file
Deleted charts/act/templates/deployment.yaml
@@ -1,160 +0,0 @@
1apiVersion: apps/v1
2kind: Deployment
3metadata:
4 name: {{ include "runner.fullname" . }}
5 labels:
6 {{- include "runner.labels" . | nindent 4 }}
7spec:
8 {{- if .Values.autoscaling.enabled }}
9 # 开着 HPA 时不要声明 replicas:HPA 通过 /scale 子资源写这个字段,
10 # kube-controller-manager 于是成为它的 manager;helm 的 server-side apply
11 # 再写一次就会报 conflict with "kube-controller-manager" with subresource
12 # "scale": .spec.replicas —— 升级直接失败;而且就算写进去,也会把已经扩上
13 # 去的副本数硬拉回 minReplicas。省略即让出所有权。
14 {{- else }}
15 replicas: {{ .Values.replicaCount }}
16 {{- end }}
17 revisionHistoryLimit: 3
18 strategy:
19 type: RollingUpdate
20 rollingUpdate:
21 maxSurge: 1
22 maxUnavailable: 0
23 selector:
24 matchLabels:
25 {{- include "runner.selectorLabels" . | nindent 6 }}
26 template:
27 metadata:
28 labels:
29 {{- include "runner.selectorLabels" . | nindent 8 }}
30 spec:
31 serviceAccountName: {{ include "runner.serviceAccountName" . }}
32 {{- include "runner.imagePullSecrets" . | nindent 6 }}
33 terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }}
34 securityContext:
35 {{- toYaml .Values.podSecurityContext | nindent 8 }}
36 {{- if .Values.scheduling.nodeSelector }}
37 nodeSelector:
38 {{- toYaml .Values.scheduling.nodeSelector | nindent 8 }}
39 {{- end }}
40 {{- if or .Values.scheduling.excludeControlPlane.enabled .Values.scheduling.affinity }}
41 affinity:
42 {{- if .Values.scheduling.excludeControlPlane.enabled }}
43 # 硬约束:不带 control-plane 标签的节点才允许调度(master-01..03 都带)
44 nodeAffinity:
45 requiredDuringSchedulingIgnoredDuringExecution:
46 nodeSelectorTerms:
47 - matchExpressions:
48 - key: {{ .Values.scheduling.excludeControlPlane.labelKey }}
49 operator: DoesNotExist
50 {{- end }}
51 {{- with .Values.scheduling.affinity }}
52 {{- toYaml . | nindent 8 }}
53 {{- end }}
54 {{- end }}
55 {{- if .Values.scheduling.tolerations }}
56 tolerations:
57 {{- toYaml . | nindent 8 }}
58 {{- end }}
59 {{- if .Values.scheduling.topologySpread.enabled }}
60 # 软性打散:允许单机多副本(ScheduleAnyway),只是倾向于摊开
61 topologySpreadConstraints:
62 - maxSkew: {{ .Values.scheduling.topologySpread.maxSkew }}
63 topologyKey: {{ .Values.scheduling.topologySpread.topologyKey }}
64 whenUnsatisfiable: {{ .Values.scheduling.topologySpread.whenUnsatisfiable }}
65 labelSelector:
66 matchLabels:
67 {{- include "runner.selectorLabels" . | nindent 14 }}
68 {{- end }}
69 initContainers:
70 # 把 act 引擎与 Node 运行时从 actions 镜像搬进共享 emptyDir,
71 # 主容器因此不必自带 act 工具链。
72 # 两个 emptyDir 分开挂载:bin 卷的根必须直接是 gitdata-actions-engine 所在目录,
73 # node 卷的根必须直接是 node12/16/20/24,才能对上 RunnerConfig 默认的
74 # /opt/gitdata/node/<version>/bin/node(合成一个卷会多出一层 node/)。
75 - name: actions-runtime
76 image: {{ include "runner.actionsImage" . }}
77 imagePullPolicy: {{ .Values.actionsImage.pullPolicy }}
78 command: ["sh", "-c"]
79 args:
80 - |
81 mkdir -p /bundle/bin /bundle/node
82 cp /usr/local/bin/gitdata-actions-engine /bundle/bin/
83 for version in /opt/gitdata/node/*/; do cp -a "$version" /bundle/node/; done
84 securityContext:
85 {{- toYaml .Values.securityContext | nindent 12 }}
86 volumeMounts:
87 - name: actions-bin
88 mountPath: /bundle/bin
89 - name: actions-node
90 mountPath: /bundle/node
91 containers:
92 - name: runner
93 image: {{ include "runner.image" . }}
94 imagePullPolicy: {{ .Values.image.pullPolicy }}
95 args: ["runner"]
96 env:
97 - name: APP_RUNNER__SERVER_URL
98 value: {{ .Values.serverUrl | quote }}
99 - name: APP_RUNNER__REGISTRATION_TOKEN
100 valueFrom:
101 secretKeyRef:
102 name: {{ include "runner.secretName" . }}
103 key: APP_RUNNER__REGISTRATION_TOKEN
104 optional: true
105 - name: APP_RUNNER__NAME
106 valueFrom:
107 fieldRef:
108 fieldPath: metadata.name
109 - name: APP_RUNNER__LABELS
110 value: {{ .Values.runner.labels | toJson | quote }}
111 - name: APP_RUNNER__CAPACITY
112 value: {{ .Values.runner.capacity | quote }}
113 - name: APP_RUNNER__WORKSPACE_ROOT
114 value: {{ .Values.runner.workspaceRoot | quote }}
115 - name: APP_RUNNER__CREDENTIAL_PATH
116 value: {{ .Values.runner.credentialPath | quote }}
117 - name: APP_RUNNER__DOCKER_ENABLED
118 value: {{ .Values.runner.dockerEnabled | quote }}
119 - name: APP_RUNNER__ACTIONS_ENGINE_BINARY
120 value: {{ .Values.runner.actionsEngineBinary | quote }}
121 - name: APP_RUNNER__ACTIONS_CACHE_DIR
122 value: {{ .Values.runner.actionsCacheDir | quote }}
123 {{- if .Values.metrics.otlpEndpoint }}
124 - name: OTEL_EXPORTER_OTLP_ENDPOINT
125 value: {{ .Values.metrics.otlpEndpoint | quote }}
126 - name: OTEL_SERVICE_NAME
127 value: "runner-pool"
128 {{- end }}
129 {{- with .Values.runner.extraEnv }}
130 {{- toYaml . | nindent 12 }}
131 {{- end }}
132 securityContext:
133 {{- toYaml .Values.securityContext | nindent 12 }}
134 resources:
135 {{- toYaml .Values.runner.resources | nindent 12 }}
136 volumeMounts:
137 - name: workspace
138 mountPath: /var/lib/gitdata-runner
139 - name: actions-bin
140 mountPath: /opt/gitdata/actions/bin
141 readOnly: true
142 - name: actions-node
143 mountPath: /opt/gitdata/node
144 readOnly: true
145 {{- if .Values.runner.dockerEnabled }}
146 - name: docker-sock
147 mountPath: /var/run/docker.sock
148 {{- end }}
149 volumes:
150 - name: workspace
151 emptyDir: {}
152 - name: actions-bin
153 emptyDir: {}
154 - name: actions-node
155 emptyDir: {}
156 {{- if .Values.runner.dockerEnabled }}
157 - name: docker-sock
158 hostPath:
159 path: /var/run/docker.sock
160 {{- end }}
No newline at end of file
Deleted charts/act/templates/hpa.yaml
@@ -1,34 +0,0 @@
1{{- if .Values.autoscaling.enabled }}
2apiVersion: autoscaling/v2
3kind: HorizontalPodAutoscaler
4metadata:
5 name: {{ include "runner.fullname" . }}
6 labels:
7 {{- include "runner.labels" . | nindent 4 }}
8spec:
9 scaleTargetRef:
10 apiVersion: apps/v1
11 kind: Deployment
12 name: {{ include "runner.fullname" . }}
13 minReplicas: {{ .Values.autoscaling.minReplicas }}
14 maxReplicas: {{ .Values.autoscaling.maxReplicas }}
15 metrics:
16 - type: Resource
17 resource:
18 name: cpu
19 target:
20 type: Utilization
21 averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
22 {{- if gt (int (.Values.autoscaling.targetMemoryUtilizationPercentage | default 0)) 0 }}
23 - type: Resource
24 resource:
25 name: memory
26 target:
27 type: Utilization
28 averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
29 {{- end }}
30 {{- with .Values.autoscaling.behavior }}
31 behavior:
32 {{- toYaml . | nindent 4 }}
33 {{- end }}
34{{- end }}
No newline at end of file
Deleted charts/act/templates/networkpolicy.yaml
@@ -1,50 +0,0 @@
1{{- if .Values.networkPolicy.enabled }}
2# 1) 拒绝一切入站:runner 是纯客户端,没有被访问的需求
3apiVersion: networking.k8s.io/v1
4kind: NetworkPolicy
5metadata:
6 name: {{ include "runner.fullname" . }}-deny-ingress
7 labels:
8 {{- include "runner.labels" . | nindent 4 }}
9spec:
10 podSelector:
11 matchLabels:
12 {{- include "runner.selectorLabels" . | nindent 6 }}
13 policyTypes: ["Ingress"]
14 ingress: []
15---
16apiVersion: networking.k8s.io/v1
17kind: NetworkPolicy
18metadata:
19 name: {{ include "runner.fullname" . }}-egress
20 labels:
21 {{- include "runner.labels" . | nindent 4 }}
22spec:
23 podSelector:
24 matchLabels:
25 {{- include "runner.selectorLabels" . | nindent 6 }}
26 policyTypes: ["Egress"]
27 egress:
28 # DNS
29 - to:
30 - namespaceSelector:
31 matchLabels:
32 kubernetes.io/metadata.name: kube-system
33 ports:
34 - protocol: UDP
35 port: 53
36 - protocol: TCP
37 port: 53
38 # 调度中心(gitdata 命名空间的 webhook Service)
39 - to:
40 - namespaceSelector:
41 matchLabels:
42 kubernetes.io/metadata.name: {{ .Values.networkPolicy.webhookNamespace }}
43 ports:
44 - protocol: TCP
45 port: {{ .Values.networkPolicy.webhookPort }}
46 {{- if .Values.networkPolicy.allowAllEgress }}
47 # workflow 步骤要能出网(git clone / 依赖下载 / 拉取 action)
48 - {}
49 {{- end }}
50{{- end }}
No newline at end of file
Deleted charts/act/templates/NOTES.txt
@@ -1,19 +0,0 @@
1GitData runner 池已部署。
2
3 命名空间: {{ .Release.Namespace }}
4 Deployment: {{ include "runner.fullname" . }}
5 调度中心: {{ .Values.serverUrl }}
6 标签: {{ .Values.runner.labels | toJson }}
7 副本: {{ if .Values.autoscaling.enabled }}HPA min {{ .Values.autoscaling.minReplicas }} / max {{ .Values.autoscaling.maxReplicas }} (cpu {{ .Values.autoscaling.targetCPUUtilizationPercentage }}%){{ else }}{{ .Values.replicaCount }}{{ end }}
8
9前置条件(helm 不管这两件事):
10 1. 注册令牌必须是可复用的:
11 UPDATE ci_runner_token SET single_use = false WHERE id = '<token-id>';
12 single_use=true 时只有第一个 pod 能注册,其余副本 401 崩溃。
13 2. Secret 必须已存在于本命名空间:
14 kubectl -n {{ .Release.Namespace }} create secret generic {{ include "runner.secretName" . }} \
15 --from-literal=APP_RUNNER__REGISTRATION_TOKEN='<id>.<secret>'
16
17查看状态:
18 kubectl -n {{ .Release.Namespace }} get deploy,hpa,pdb,pods -o wide
19 kubectl -n {{ .Release.Namespace }} logs -l app.kubernetes.io/component=runner -f
No newline at end of file
Deleted charts/act/templates/pdb.yaml
@@ -1,13 +0,0 @@
1{{- if .Values.podDisruptionBudget.enabled }}
2apiVersion: policy/v1
3kind: PodDisruptionBudget
4metadata:
5 name: {{ include "runner.fullname" . }}
6 labels:
7 {{- include "runner.labels" . | nindent 4 }}
8spec:
9 minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
10 selector:
11 matchLabels:
12 {{- include "runner.selectorLabels" . | nindent 6 }}
13{{- end }}
No newline at end of file
Deleted charts/act/templates/secret.yaml
@@ -1,13 +0,0 @@
1{{- if and .Values.secret.create (not .Values.secret.existingSecret) }}
2apiVersion: v1
3kind: Secret
4metadata:
5 name: {{ include "runner.secretName" . }}
6 labels:
7 {{- include "runner.labels" . | nindent 4 }}
8type: Opaque
9stringData:
10 {{- range $k, $v := .Values.secret.env }}
11 {{ $k }}: {{ $v | quote }}
12 {{- end }}
13{{- end }}
No newline at end of file
Deleted charts/act/templates/serviceaccount.yaml
@@ -1,14 +0,0 @@
1{{- if .Values.serviceAccount.create }}
2apiVersion: v1
3kind: ServiceAccount
4metadata:
5 name: {{ include "runner.serviceAccountName" . }}
6 labels:
7 {{- include "runner.labels" . | nindent 4 }}
8 {{- with .Values.serviceAccount.annotations }}
9 annotations:
10 {{- toYaml . | nindent 4 }}
11 {{- end }}
12# 不绑定任何 Role/ClusterRole:runner 只通过 HTTP 访问调度中心,
13# 从不调用 kube-apiserver,因此不需要(也不应该拥有)任何 RBAC 权限。
14{{- end }}
No newline at end of file
Deleted charts/act/Chart.yaml
@@ -1,6 +0,0 @@
1apiVersion: v2
2name: chart-runner
3description: GitData actions runner 池(部署到独立命名空间,调度中心在 gitdata 命名空间)
4type: application
5version: 0.1.0
6appVersion: "1.0.0"
No newline at end of file
Deleted charts/act/values.yaml
@@ -1,110 +0,0 @@
1# GitData actions runner 池的默认值。
2# 生产覆盖见 deploy/values/runner-prod.yaml。
3nameOverride: ""
4fullnameOverride: "gitdata-runner"
5
6# 调度中心(gitdata 命名空间的 webhook Service)地址。
7# 跨命名空间必须写全限定域名:
8# <service>.<namespace>.svc.cluster.local:<port>
9serverUrl: "http://gitdata-webhook.gitdata.svc.cluster.local:3005"
10
11# OTLP 导出端点(与主 chart 的 metrics.otlpEndpoint 对齐;留空则只输出日志)。
12metrics:
13 otlpEndpoint: ""
14
15image:
16 repository: harbor.gitdata.me/app/gitdata
17 tag: ""
18 pullPolicy: IfNotPresent
19
20# act 引擎镜像:提供 gitdata-actions-engine 二进制 + 4 套 Node 运行时。
21# 引擎已独立成仓(git-data-actions),版本不再跟主镜像走,必须显式写 tag。
22actionsImage:
23 repository: harbor.gitdata.me/app/gitdata-act
24 tag: "0.2.89"
25 pullPolicy: IfNotPresent
26
27imagePullSecrets: []
28
29serviceAccount:
30 create: true
31 name: ""
32 annotations: {}
33
34# 注册令牌(APP_RUNNER__REGISTRATION_TOKEN)。
35# 生产用 existingSecret:Secret 由 kubectl 直接建在集群里,任何一次 helm upgrade
36# 都不会把它覆盖或清空(写进 values 的令牌一旦漏传 -f 就会被空值刷掉)。
37secret:
38 create: false
39 existingSecret: "gitdata-runner-env"
40 env: {}
41
42runner:
43 # 不含 docker:只注册 self-hosted 标签。带 ubuntu-latest 等标签的 workflow
44 # 不会被接单(要接单需要 dockerEnabled + 拉 ghcr.io/catthehacker/ubuntu)。
45 labels: ["self-hosted"]
46 # 每个副本同时只跑 1 个作业;并发度靠副本数
47 capacity: 1
48 dockerEnabled: false
49 workspaceRoot: /var/lib/gitdata-runner
50 credentialPath: /var/lib/gitdata-runner/credential
51 actionsEngineBinary: /opt/gitdata/actions/bin/gitdata-actions-engine
52 actionsCacheDir: /var/lib/gitdata-runner/actions
53 extraEnv: []
54 resources:
55 requests: { cpu: 500m, memory: 512Mi }
56 limits: { memory: 4Gi }
57
58# 仅在 autoscaling.enabled=false 时生效
59replicaCount: 3
60
61autoscaling:
62 enabled: true
63 minReplicas: 3
64 maxReplicas: 6
65 targetCPUUtilizationPercentage: 70
66 # 0 = 不启用内存指标
67 targetMemoryUtilizationPercentage: 0
68 behavior: {}
69
70scheduling:
71 # 排除 master / 控制面节点
72 excludeControlPlane:
73 enabled: true
74 labelKey: node-role.kubernetes.io/control-plane
75 nodeSelector: {}
76 tolerations: []
77 affinity: {}
78 # 软性打散:maxSkew=1 + ScheduleAnyway,既不堆在一台,也不禁止单机多副本。
79 # 扩容时新副本由 kube-scheduler 默认的 LeastAllocated 打分挑当前请求量最小
80 # 的节点,即"负载由小到大"。
81 topologySpread:
82 enabled: true
83 maxSkew: 1
84 topologyKey: kubernetes.io/hostname
85 whenUnsatisfiable: ScheduleAnyway
86
87podDisruptionBudget:
88 enabled: true
89 minAvailable: 2
90
91networkPolicy:
92 enabled: true
93 # 调度中心所在的命名空间与端口(允许 egress)
94 webhookNamespace: gitdata
95 webhookPort: 3005
96 # workflow 步骤需要上网(git clone / 包下载 / 拉 action),默认放行全部出站
97 allowAllEgress: true
98
99podSecurityContext:
100 fsGroup: 10001
101securityContext:
102 runAsNonRoot: true
103 runAsUser: 10001
104 allowPrivilegeEscalation: false
105 seccompProfile:
106 type: RuntimeDefault
107 capabilities:
108 drop: ["ALL"]
109
110terminationGracePeriodSeconds: 300
No newline at end of file
Modified charts/core/templates/_helpers.tpl View charts/core/templates/_helpers.tpl
@@ -45,11 +45,6 @@
4545{{- printf "%s:%s" .Values.image.repository $tag -}}
4646{{- end -}}
4747
48{{- define "gitdata.actionsImage" -}}
49{{- $tag := .Values.runner.actionsImage.tag | default .Chart.AppVersion -}}
50{{- printf "%s:%s" .Values.runner.actionsImage.repository $tag -}}
51{{- end -}}
52
5348{{- define "gitdata.secretName" -}}
5449{{- if .Values.secret.existingSecret -}}
5550{{- .Values.secret.existingSecret -}}
@@ -64,10 +59,6 @@
6459 value: {{ .Values.apihost.port | quote }}
6560- name: APP_IM__PORT
6661 value: {{ .Values.im.port | quote }}
67- name: APP_HEALTH__ENABLED
68 value: {{ .Values.health.enabled | quote }}
69- name: APP_HEALTH__SYNCHOST__PORT
70 value: {{ .Values.health.synchostPort | quote }}
7162- name: APP_GIT__KENSHELL_ENDPOINT
7263 value: {{ .Values.kenshell.endpoint | quote }}
7364- name: APP_GIT__KENSHELL_RPC_KEY
@@ -87,11 +78,6 @@
8778 value: {{ default $base .Values.config.env.APP_GIT__BASE_URL | quote }}
8879- name: APP_GIT__HTTP_CLONE_BASE_URL
8980 value: {{ default $base .Values.config.env.APP_GIT__HTTP_CLONE_BASE_URL | quote }}
90# CI Actions runtime 回调 origin:必须是纯 origin(lib/ci/src/actions_runtime.rs:158 用
91# reqwest::Url::parse 校验,带路径前缀就直接报错),且必须能被 runner 解析——runner 池
92# 走独立 chart 部署在别的命名空间,所以默认值用跨命名空间 FQDN,而不是裸 Service 名。
93- name: APP_CI__RUNTIME_BASE_URL
94 value: {{ default (printf "http://%s-webhook.%s.svc.cluster.local:%v" (include "gitdata.fullname" .) .Release.Namespace .Values.webhook.port) .Values.config.env.APP_CI__RUNTIME_BASE_URL | quote }}
9581{{- if .Values.metrics.otlpEndpoint }}
9682- name: OTEL_EXPORTER_OTLP_ENDPOINT
9783 value: {{ .Values.metrics.otlpEndpoint | quote }}
@@ -105,14 +91,12 @@
10591{{- range $k, $v := .Values.config.env }}
10692{{- if not (hasPrefix "APP_WEB__WEB_BASE_URL" $k) }}
10793{{- if not (hasPrefix "APP_GIT__BASE_URL" $k) }}
108{{- if not (hasPrefix "APP_CI__RUNTIME_BASE_URL" $k) }}
10994{{/* config.env 走容器 env:,secret.env 走 envFrom:;同名时 env: 优先级更高,
11095 会静默覆盖 Secret 里的真实值(例如把连接串换成 chart 默认的 redis://redis-master)。
11196 因此这里跳过任何已在 secret.env 中声明的键,让 Secret 成为唯一来源。 */}}
11297{{- if not (hasKey $.Values.secret.env $k) }}
11398- name: {{ $k }}
11499 value: {{ $v | quote }}
115{{- end }}
116100{{- end }}
117101{{- end }}
118102{{- end }}
Deleted charts/core/templates/runner.yaml
@@ -1,114 +0,0 @@
1{{- if .Values.runner.enabled }}
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 name: {{ printf "%s-runner" (include "gitdata.fullname" .) }}
6 labels:
7 {{- include "gitdata.labels" . | nindent 4 }}
8 app.kubernetes.io/component: runner
9spec:
10 replicas: {{ .Values.runner.replicaCount }}
11 revisionHistoryLimit: 3
12 selector:
13 matchLabels:
14 {{- include "gitdata.selectorLabels" . | nindent 6 }}
15 app.kubernetes.io/component: runner
16 template:
17 metadata:
18 labels:
19 {{- include "gitdata.selectorLabels" . | nindent 8 }}
20 app.kubernetes.io/component: runner
21 spec:
22 serviceAccountName: {{ include "gitdata.serviceAccountName" . }}
23 {{- include "gitdata.imagePullSecrets" . | nindent 6 }}
24 terminationGracePeriodSeconds: 300
25 securityContext:
26 {{- toYaml .Values.podSecurityContext | nindent 8 }}
27 initContainers:
28 - name: actions-runtime
29 image: {{ include "gitdata.actionsImage" . }}
30 imagePullPolicy: {{ .Values.runner.actionsImage.pullPolicy }}
31 command: ["sh", "-c"]
32 args:
33 - |
34 mkdir -p /bundle/bin /bundle/node
35 cp /usr/local/bin/gitdata-actions-engine /bundle/bin/
36 for version in /opt/gitdata/node/*/; do cp -a "$version" /bundle/node/; done
37 securityContext:
38 {{- toYaml .Values.securityContext | nindent 12 }}
39 volumeMounts:
40 - name: actions-bin
41 mountPath: /bundle/bin
42 - name: actions-node
43 mountPath: /bundle/node
44 containers:
45 - name: runner
46 image: {{ include "gitdata.image" . }}
47 imagePullPolicy: {{ .Values.image.pullPolicy }}
48 args: ["runner"]
49 env:
50 - name: APP_RUNNER__SERVER_URL
51 value: {{ default (printf "http://%s-webhook:%v" (include "gitdata.fullname" .) .Values.webhook.port) .Values.runner.serverUrl | quote }}
52 - name: APP_RUNNER__REGISTRATION_TOKEN
53 valueFrom:
54 secretKeyRef:
55 name: {{ include "gitdata.secretName" . }}
56 key: APP_RUNNER__REGISTRATION_TOKEN
57 optional: true
58 - name: APP_RUNNER__NAME
59 valueFrom:
60 fieldRef:
61 fieldPath: metadata.name
62 - name: APP_RUNNER__LABELS
63 value: {{ .Values.runner.labels | toJson | quote }}
64 - name: APP_RUNNER__CAPACITY
65 value: {{ .Values.runner.capacity | quote }}
66 - name: APP_RUNNER__WORKSPACE_ROOT
67 value: /var/lib/gitdata-runner
68 - name: APP_RUNNER__CREDENTIAL_PATH
69 value: /var/lib/gitdata-runner/credential
70 - name: APP_RUNNER__DOCKER_ENABLED
71 value: {{ .Values.runner.dockerEnabled | quote }}
72 - name: APP_RUNNER__ACTIONS_ENGINE_BINARY
73 value: /opt/gitdata/actions/bin/gitdata-actions-engine
74 - name: APP_RUNNER__ACTIONS_CACHE_DIR
75 value: /var/lib/gitdata-runner/actions
76 {{- if .Values.metrics.otlpEndpoint }}
77 - name: OTEL_EXPORTER_OTLP_ENDPOINT
78 value: {{ .Values.metrics.otlpEndpoint | quote }}
79 - name: OTEL_SERVICE_NAME
80 value: "runner"
81 {{- end }}
82 {{- with .Values.runner.extraEnv }}
83 {{- toYaml . | nindent 12 }}
84 {{- end }}
85 securityContext:
86 {{- toYaml .Values.securityContext | nindent 12 }}
87 resources:
88 {{- toYaml .Values.runner.resources | nindent 12 }}
89 volumeMounts:
90 - name: workspace
91 mountPath: /var/lib/gitdata-runner
92 - name: actions-bin
93 mountPath: /opt/gitdata/actions/bin
94 readOnly: true
95 - name: actions-node
96 mountPath: /opt/gitdata/node
97 readOnly: true
98 {{- if .Values.runner.dockerEnabled }}
99 - name: docker-sock
100 mountPath: /var/run/docker.sock
101 {{- end }}
102 volumes:
103 - name: workspace
104 emptyDir: {}
105 - name: actions-bin
106 emptyDir: {}
107 - name: actions-node
108 emptyDir: {}
109 {{- if .Values.runner.dockerEnabled }}
110 - name: docker-sock
111 hostPath:
112 path: /var/run/docker.sock
113 {{- end }}
114{{- end }}
Deleted charts/core/templates/searchhost.yaml
@@ -1,67 +0,0 @@
1{{- if .Values.searchhost.enabled }}
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 name: {{ printf "%s-searchhost" (include "gitdata.fullname" .) }}
6 labels:
7 {{- include "gitdata.labels" . | nindent 4 }}
8 app.kubernetes.io/component: searchhost
9spec:
10 replicas: {{ .Values.searchhost.replicaCount }}
11 revisionHistoryLimit: 3
12 selector:
13 matchLabels:
14 {{- include "gitdata.selectorLabels" . | nindent 6 }}
15 app.kubernetes.io/component: searchhost
16 template:
17 metadata:
18 labels:
19 {{- include "gitdata.selectorLabels" . | nindent 8 }}
20 app.kubernetes.io/component: searchhost
21 {{- with .Values.podLabels }}
22 {{- toYaml . | nindent 8 }}
23 {{- end }}
24 annotations:
25 checksum/env: {{ toYaml .Values.config.env | sha256sum }}
26 {{- with .Values.podAnnotations }}
27 {{- toYaml . | nindent 8 }}
28 {{- end }}
29 spec:
30 serviceAccountName: {{ include "gitdata.serviceAccountName" . }}
31 {{- include "gitdata.imagePullSecrets" . | nindent 6 }}
32 # 索引任务会在 git 子进程里读仓库,留足 drain 时间
33 terminationGracePeriodSeconds: 600
34 securityContext:
35 {{- toYaml .Values.podSecurityContext | nindent 8 }}
36 containers:
37 # app/searchhost 只跑 SearchWorker,不监听 HTTP 端口,所以没有探针;
38 # 进程退出即由 kubelet 重启。
39 - name: searchhost
40 image: {{ include "gitdata.image" . }}
41 imagePullPolicy: {{ .Values.image.pullPolicy }}
42 args: ["searchhost"]
43 {{- include "gitdata.envFrom" . | nindent 10 }}
44 env:
45 {{- include "gitdata.env" . | nindent 12 }}
46 {{- with .Values.searchhost.extraEnv }}
47 {{- toYaml . | nindent 12 }}
48 {{- end }}
49 securityContext:
50 {{- toYaml .Values.securityContext | nindent 12 }}
51 resources:
52 {{- toYaml .Values.searchhost.resources | nindent 12 }}
53 volumeMounts:
54 - name: repos
55 mountPath: {{ .Values.persistence.repos.mountPath }}
56 volumes:
57 - name: repos
58 {{- if .Values.persistence.repos.existingClaim }}
59 persistentVolumeClaim:
60 claimName: {{ .Values.persistence.repos.existingClaim }}
61 {{- else if .Values.persistence.repos.enabled }}
62 persistentVolumeClaim:
63 claimName: {{ printf "%s-repos" (include "gitdata.fullname" .) }}
64 {{- else }}
65 emptyDir: {}
66 {{- end }}
67{{- end }}
No newline at end of file
Deleted charts/core/templates/synchost.yaml
@@ -1,77 +0,0 @@
1{{- if .Values.synchost.enabled }}
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 name: {{ printf "%s-synchost" (include "gitdata.fullname" .) }}
6 labels:
7 {{- include "gitdata.labels" . | nindent 4 }}
8 app.kubernetes.io/component: synchost
9spec:
10 replicas: {{ .Values.synchost.replicaCount }}
11 revisionHistoryLimit: 3
12 selector:
13 matchLabels:
14 {{- include "gitdata.selectorLabels" . | nindent 6 }}
15 app.kubernetes.io/component: synchost
16 template:
17 metadata:
18 labels:
19 {{- include "gitdata.selectorLabels" . | nindent 8 }}
20 app.kubernetes.io/component: synchost
21 {{- with .Values.podLabels }}
22 {{- toYaml . | nindent 8 }}
23 {{- end }}
24 annotations:
25 checksum/env: {{ toYaml .Values.config.env | sha256sum }}
26 {{- with .Values.podAnnotations }}
27 {{- toYaml . | nindent 8 }}
28 {{- end }}
29 spec:
30 serviceAccountName: {{ include "gitdata.serviceAccountName" . }}
31 {{- include "gitdata.imagePullSecrets" . | nindent 6 }}
32 # push 后同步任务含 git 子进程,给足 drain 时间
33 terminationGracePeriodSeconds: 600
34 securityContext:
35 {{- toYaml .Values.podSecurityContext | nindent 8 }}
36 containers:
37 - name: synchost
38 image: {{ include "gitdata.image" . }}
39 imagePullPolicy: {{ .Values.image.pullPolicy }}
40 args: ["synchost"]
41 {{- include "gitdata.envFrom" . | nindent 10 }}
42 env:
43 {{- include "gitdata.env" . | nindent 12 }}
44 {{- with .Values.synchost.extraEnv }}
45 {{- toYaml . | nindent 12 }}
46 {{- end }}
47 ports:
48 - name: health
49 containerPort: {{ .Values.health.synchostPort }}
50 livenessProbe:
51 httpGet: { path: /healthz, port: health }
52 initialDelaySeconds: 10
53 periodSeconds: 15
54 readinessProbe:
55 httpGet: { path: /readyz, port: health }
56 initialDelaySeconds: 5
57 periodSeconds: 10
58 failureThreshold: 3
59 securityContext:
60 {{- toYaml .Values.securityContext | nindent 12 }}
61 resources:
62 {{- toYaml .Values.synchost.resources | nindent 12 }}
63 volumeMounts:
64 - name: repos
65 mountPath: {{ .Values.persistence.repos.mountPath }}
66 volumes:
67 - name: repos
68 {{- if .Values.persistence.repos.existingClaim }}
69 persistentVolumeClaim:
70 claimName: {{ .Values.persistence.repos.existingClaim }}
71 {{- else if .Values.persistence.repos.enabled }}
72 persistentVolumeClaim:
73 claimName: {{ printf "%s-repos" (include "gitdata.fullname" .) }}
74 {{- else }}
75 emptyDir: {}
76 {{- end }}
77{{- end }}
Deleted charts/core/templates/webhook.yaml
@@ -1,135 +0,0 @@
1{{- if .Values.webhook.enabled }}
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 name: {{ printf "%s-webhook" (include "gitdata.fullname" .) }}
6 labels:
7 {{- include "gitdata.labels" . | nindent 4 }}
8 app.kubernetes.io/component: webhook
9spec:
10 replicas: {{ .Values.webhook.replicaCount }}
11 revisionHistoryLimit: 3
12 selector:
13 matchLabels:
14 {{- include "gitdata.selectorLabels" . | nindent 6 }}
15 app.kubernetes.io/component: webhook
16 template:
17 metadata:
18 labels:
19 {{- include "gitdata.selectorLabels" . | nindent 8 }}
20 app.kubernetes.io/component: webhook
21 {{- with .Values.podLabels }}
22 {{- toYaml . | nindent 8 }}
23 {{- end }}
24 annotations:
25 checksum/env: {{ toYaml .Values.config.env | sha256sum }}
26 {{- with .Values.podAnnotations }}
27 {{- toYaml . | nindent 8 }}
28 {{- end }}
29 spec:
30 serviceAccountName: {{ include "gitdata.serviceAccountName" . }}
31 {{- include "gitdata.imagePullSecrets" . | nindent 6 }}
32 terminationGracePeriodSeconds: 60
33 securityContext:
34 {{- toYaml .Values.podSecurityContext | nindent 8 }}
35 containers:
36 - name: webhook
37 image: {{ include "gitdata.image" . }}
38 imagePullPolicy: {{ .Values.image.pullPolicy }}
39 args: ["webhook"]
40 {{- include "gitdata.envFrom" . | nindent 10 }}
41 env:
42 {{- include "gitdata.env" . | nindent 12 }}
43 - name: APP_WEBHOOK__PORT
44 value: {{ .Values.webhook.port | quote }}
45 - name: APP_WEBHOOK__PUBLIC_BASE_URL
46 value: {{ default (printf "https://%s" .Values.webhook.ingress.host) .Values.config.env.APP_WEBHOOK__PUBLIC_BASE_URL | quote }}
47 {{- with .Values.webhook.extraEnv }}
48 {{- toYaml . | nindent 12 }}
49 {{- end }}
50 ports:
51 - name: http
52 containerPort: {{ .Values.webhook.port }}
53 livenessProbe:
54 httpGet: { path: /healthz, port: http }
55 initialDelaySeconds: 10
56 periodSeconds: 15
57 readinessProbe:
58 httpGet: { path: /readyz, port: http }
59 initialDelaySeconds: 5
60 periodSeconds: 10
61 failureThreshold: 3
62 securityContext:
63 {{- toYaml .Values.securityContext | nindent 12 }}
64 resources:
65 {{- toYaml .Values.webhook.resources | nindent 12 }}
66 volumeMounts:
67 - name: repos
68 mountPath: {{ .Values.persistence.repos.mountPath }}
69 volumes:
70 - name: repos
71 {{- if .Values.persistence.repos.existingClaim }}
72 persistentVolumeClaim:
73 claimName: {{ .Values.persistence.repos.existingClaim }}
74 {{- else if .Values.persistence.repos.enabled }}
75 persistentVolumeClaim:
76 claimName: {{ printf "%s-repos" (include "gitdata.fullname" .) }}
77 {{- else }}
78 emptyDir: {}
79 {{- end }}
80---
81apiVersion: v1
82kind: Service
83metadata:
84 name: {{ printf "%s-webhook" (include "gitdata.fullname" .) }}
85 labels:
86 {{- include "gitdata.labels" . | nindent 4 }}
87 app.kubernetes.io/component: webhook
88spec:
89 type: ClusterIP
90 selector:
91 {{- include "gitdata.selectorLabels" . | nindent 4 }}
92 app.kubernetes.io/component: webhook
93 ports:
94 - name: http
95 port: {{ .Values.webhook.port }}
96 targetPort: http
97{{- if .Values.webhook.ingress.enabled }}
98---
99apiVersion: networking.k8s.io/v1
100kind: Ingress
101metadata:
102 name: {{ printf "%s-webhook" (include "gitdata.fullname" .) }}
103 labels:
104 {{- include "gitdata.labels" . | nindent 4 }}
105 app.kubernetes.io/component: webhook
106 annotations:
107 {{- with .Values.webhook.ingress.annotations }}
108 {{- toYaml . | nindent 4 }}
109 {{- end }}
110 {{- if and .Values.webhook.ingress.tls.enabled .Values.webhook.ingress.tls.clusterIssuer }}
111 cert-manager.io/cluster-issuer: {{ .Values.webhook.ingress.tls.clusterIssuer }}
112 {{- end }}
113spec:
114 {{- with .Values.webhook.ingress.className }}
115 ingressClassName: {{ . }}
116 {{- end }}
117 {{- if .Values.webhook.ingress.tls.enabled }}
118 tls:
119 - hosts:
120 - {{ .Values.webhook.ingress.host | quote }}
121 secretName: {{ default (printf "%s-webhook-tls" (include "gitdata.fullname" .)) .Values.webhook.ingress.tls.secretName }}
122 {{- end }}
123 rules:
124 - host: {{ .Values.webhook.ingress.host | quote }}
125 http:
126 paths:
127 - path: /
128 pathType: Prefix
129 backend:
130 service:
131 name: {{ printf "%s-webhook" (include "gitdata.fullname" .) }}
132 port:
133 name: http
134{{- end }}
135{{- end }}
Modified charts/core/Chart.yaml View charts/core/Chart.yaml
@@ -1,6 +1,6 @@
11apiVersion: v2
22name: gitdata
3description: GitData — Rust workspace (apihost / synchost / webhook / migrate)
3description: GitData — Rust workspace (apihost / im / migrate)
44type: application
55version: 0.1.0
66appVersion: "1.0.0"
Modified charts/core/values.yaml View charts/core/values.yaml
@@ -30,17 +30,11 @@
3030 APP_S3__PATH_STYLE: "true"
3131 # 读写库/副本、配额等其余段均可在此覆盖,例如:
3232 # APP_DATABASE__REPLICAS: '[{url="postgres://readonly@pg:5432/gitdata"}]'
33 APP_SEARCH__ENABLED: "false"
34 APP_SEARCH__URL: "http://meilisearch:7700"
35 APP_SEARCH__INDEX_PREFIX: "gitdata"
36 APP_IMPORT__ENABLED: "false"
3733 # Web Push:默认关闭;开启前必须同时提供 APP_PUSH__VAPID_PUBLIC_KEY(此处)
3834 # 与 APP_PUSH__VAPID_PRIVATE_KEY(secret.env)。密钥生成见 config/default.toml。
3935 APP_PUSH__ENABLED: "false"
4036 APP_PUSH__VAPID_PUBLIC_KEY: ""
4137 APP_PUSH__VAPID_SUBJECT: "mailto:ops@example.com"
42 # 自建 forge 的内网地址需显式加白,例如:
43 # APP_IMPORT__ALLOWED_HOSTS: '["gitea.internal","gitlab.svc.cluster.local"]'
4438 # 认证中心:浏览器走 OIDC,服务间走 gRPC(x-rpc-key)。
4539 APP_OAUTH__ENABLED: "true"
4640 APP_OAUTH__ISSUER: "https://auth.gitdata.ai"
@@ -64,9 +58,6 @@
6458 APP_AUTH__TOTP_ENCRYPTION_KEY: "" # base64 >=32 字节(实测必填)
6559 APP_S3__ACCESS_KEY_ID: ""
6660 APP_S3__SECRET_ACCESS_KEY: ""
67 APP_SEARCH__API_KEY: ""
68 APP_IMPORT__TOKEN_SECRET: "" # >=32 字节,加密导入任务里的源站令牌
69 APP_CI__SECRET_ENCRYPTION_KEY: "" # base64 >=32 字节,CI secrets / webhook 加密密钥
7061 APP_PUSH__VAPID_PRIVATE_KEY: "" # base64url 32 字节 P-256 标量,Web Push 签名私钥
7162
7263podSecurityContext:
@@ -143,28 +134,6 @@
143134 sshCloneHost: ""
144135 sshClonePort: 22
145136
146synchost:
147 enabled: true
148 replicaCount: 2
149 resources:
150 requests: { cpu: 200m, memory: 192Mi }
151 limits: { memory: 768Mi }
152 extraEnv: []
153
154# 独立增量索引 worker(app/searchhost)。二进制不监听端口,因此没有探针。
155searchhost:
156 enabled: false
157 replicaCount: 1
158 resources:
159 requests: { cpu: 200m, memory: 256Mi }
160 limits: { memory: 1Gi }
161 extraEnv: []
162
163# worker 类程序的独立健康端点(apihost/im 挂在自己业务端口上)
164health:
165 enabled: true
166 synchostPort: 3002
167
168137im:
169138 enabled: true
170139 replicaCount: 2
@@ -191,7 +160,7 @@
191160 minAvailable: 1
192161
193162# ---------------------------------------------------------------------------
194# 存储:保留给 search/synchost 的本地缓存目录(bare 仓库已迁到 KenShell)
163# 存储:apihost 使用的本地仓库/对象缓存目录(bare 仓库已迁到 KenShell)
195164# ---------------------------------------------------------------------------
196165persistence:
197166 repos:
@@ -225,38 +194,3 @@
225194metrics:
226195 # OTLP 推送(无 scrape 端点,见 §13)
227196 otlpEndpoint: ""
228
229webhook:
230 enabled: true
231 replicaCount: 1
232 port: 3005
233 resources:
234 requests: { cpu: 100m, memory: 128Mi }
235 limits: { memory: 512Mi }
236 extraEnv: []
237 # 外部 runner 通过该域名接入调度中心;留空则仅集群内 Service 可达
238 ingress:
239 enabled: true
240 className: nginx
241 host: hooks.gitdata.example.com
242 annotations: {}
243 tls:
244 enabled: false
245 secretName: ""
246
247runner:
248 # 默认关闭:多数部署在自有机器上运行 runner 二进制
249 enabled: false
250 actionsImage:
251 repository: gitdata-act
252 tag: ""
253 pullPolicy: IfNotPresent
254 replicaCount: 1
255 labels: ["self-hosted", "docker"]
256 capacity: 1
257 dockerEnabled: false
258 serverUrl: ""
259 resources:
260 requests: { cpu: 500m, memory: 512Mi }
261 limits: { memory: 4Gi }
262 extraEnv: []
Modified charts/prod/values/prod.yaml View charts/prod/values/prod.yaml
@@ -15,9 +15,6 @@
1515 APP_S3__BUCKET: "gitdata"
1616 APP_S3__REGION: "us-east-1"
1717 APP_S3__PATH_STYLE: "true"
18 APP_SEARCH__ENABLED: "true"
19 APP_SEARCH__URL: "http://meilisearch.meilisearch.svc.cluster.local:7700"
20 APP_SEARCH__INDEX_PREFIX: "gitdata"
2118 APP_LIVEKIT__ENABLED: "true"
2219 APP_LIVEKIT__URL: "wss://livekit.gitdata.ai"
2320 APP_LIVEKIT__API_URL: "https://livekit.gitdata.ai"
@@ -29,15 +26,6 @@
2926 APP_OAUTH__COOKIE_SECURE: "true"
3027 APP_GIT__BASE_URL: "https://gitdata.ai"
3128 APP_LIVEKIT__RECORDING_ENABLED: "false"
32 # CI 开关:runner 注册走 /api/v1/runner/register,该处理函数第一件事就是
33 # ensure_enabled()(lib/ci/src/service.rs:46),关闭时直接返回 403 Forbidden。
34 # 开启后必须同时提供 APP_CI__SECRET_ENCRYPTION_KEY(已在 gitdata-env Secret 中)。
35 APP_CI__ENABLED: "true"
36 # 必须是纯 origin(无路径前缀):lib/ci/src/actions_runtime.rs:158 actions_runtime_url()
37 # 会用 reqwest::Url::parse 校验,带路径就报 "must be a URL origin without a path prefix"。
38 # 用跨命名空间 FQDN:runner 池在 gitdata-runners 命名空间,这个 URL 会随 claim
39 # 响应下发(lib/ci/src/actions_payload.rs:87 runtime_url),必须两边都能解析。
40 APP_CI__RUNTIME_BASE_URL: "http://gitdata-webhook.gitdata.svc.cluster.local:3005"
4129 RUST_LOG: "info"
4230 OTEL_TRACES_SAMPLER: "parentbased_traceidratio"
4331 OTEL_TRACES_SAMPLER_ARG: "0.2"
@@ -98,16 +86,6 @@
9886 sshCloneHost: "ssh.gitdata.ai"
9987 sshClonePort: 22
10088
101webhook:
102 enabled: true
103 ingress:
104 enabled: true
105 host: hooks.gitdata.ai
106 className: traefik
107 tls:
108 enabled: true
109 secretName: ""
110 clusterIssuer: cloudflare-acme-cluster-issuer
11189
11290persistence:
11391 repos:
@@ -121,16 +99,7 @@
12199migrate:
122100 enabled: true
123101
124synchost:
125 enabled: true
126 replicaCount: 2
127102
128# 独立增量索引 worker(app/searchhost,无 HTTP 端口);索引任务按 CAS 抢占,
129# 与 synchost 自带的 worker 并存安全。
130searchhost:
131 enabled: true
132 replicaCount: 1
133
134103im:
135104 enabled: true
136105 autoscaling:
@@ -156,11 +125,3 @@
156125 - type: Pods
157126 value: 1
158127 periodSeconds: 120
159
160runner:
161 enabled: false
162 actionsImage:
163 repository: harbor.gitdata.me/app/gitdata-act
164 # 引擎独立成仓,版本与主镜像解耦
165 tag: "0.2.89"
166 pullPolicy: IfNotPresent
Deleted charts/prod/values/runner-prod.yaml
@@ -1,87 +0,0 @@
1# GitData runner 池的生产覆盖值(不含任何密钥)。
2#
3# 部署 / 升级:
4# helm upgrade --install gitdata-runners ./chart-runner \
5# -n gitdata-runners --create-namespace \
6# -f deploy/values/runner-prod.yaml \
7# --wait --timeout 10m --atomic
8#
9# 注册令牌由 Secret 提供(existingSecret),不经过 helm:
10# kubectl -n gitdata-runners create secret generic gitdata-runner-env \
11# --from-literal=APP_RUNNER__REGISTRATION_TOKEN='<token-id>.<token-secret>'
12
13image:
14 repository: harbor.gitdata.me/app/gitdata
15 tag: "fad06fd"
16 pullPolicy: IfNotPresent
17
18actionsImage:
19 repository: harbor.gitdata.me/app/gitdata-act
20 # 引擎已独立成仓(git-data-actions),不跟随 image.tag,按引擎自己的版本号走
21 tag: "0.2.89"
22 pullPolicy: IfNotPresent
23
24serviceAccount:
25 create: true
26
27secret:
28 create: false
29 existingSecret: gitdata-runner-env
30
31# 跨命名空间访问调度中心
32serverUrl: "http://gitdata-webhook.gitdata.svc.cluster.local:3005"
33
34runner:
35 labels: ["self-hosted"]
36 capacity: 1
37 dockerEnabled: false
38 resources:
39 requests: { cpu: 500m, memory: 512Mi }
40 limits: { memory: 4Gi }
41
42metrics:
43 otlpEndpoint: "http://otel-collector.monitoring.svc.cluster.local:4318"
44
45autoscaling:
46 enabled: true
47 minReplicas: 3
48 maxReplicas: 6
49 targetCPUUtilizationPercentage: 70
50 targetMemoryUtilizationPercentage: 0
51 behavior:
52 scaleUp:
53 stabilizationWindowSeconds: 30
54 policies:
55 - type: Percent
56 value: 100
57 periodSeconds: 60
58 - type: Pods
59 value: 2
60 periodSeconds: 60
61 selectPolicy: Max
62 scaleDown:
63 stabilizationWindowSeconds: 600
64 policies:
65 - type: Pods
66 value: 1
67 periodSeconds: 120
68
69scheduling:
70 excludeControlPlane:
71 enabled: true
72 labelKey: node-role.kubernetes.io/control-plane
73 topologySpread:
74 enabled: true
75 maxSkew: 1
76 topologyKey: kubernetes.io/hostname
77 whenUnsatisfiable: ScheduleAnyway
78
79podDisruptionBudget:
80 enabled: true
81 minAvailable: 2
82
83networkPolicy:
84 enabled: true
85 webhookNamespace: gitdata
86 webhookPort: 3005
87 allowAllEgress: true
No newline at end of file
Modified config/default.toml View config/default.toml
@@ -17,36 +17,6 @@
1717username = ""
1818password = ""
1919
20[search]
21enabled = false
22url = "http://127.0.0.1:7700"
23api_key = ""
24index_prefix = "gitdata"
25max_file_bytes = 1048576
26chunk_lines = 160
27batch_size = 100
28poll_interval_secs = 2
29task_timeout_secs = 120
30incremental = true
31incremental_max_changed_files = 2000
32git_timeout_secs = 120
33cat_file_batch = true
34parse_threads = 4
35meili_inflight = 4
36
37[import]
38enabled = false
39token_secret = ""
40allowed_hosts = []
41allow_insecure_tls = false
42max_concurrent_jobs = 2
43clone_timeout_secs = 3600
44api_timeout_secs = 30
45poll_interval_secs = 3
46max_attempts = 5
47max_repo_bytes = 0
48stale_lock_secs = 1800
49
5020[database]
5121url = "postgres://postgres:postgres@localhost:5432/gitdata"
5222host = ""
@@ -198,18 +168,6 @@
198168kenshell_rpc_key = ""
199169kenshell_timeout_secs = 3600
200170
201[git.sync]
202lock_ttl_secs = 120
203max_commits_per_sync = 5000
204concurrency = 4
205queue_topic = "gitdata:tasks"
206dlq_topic = "gitdata:dlq"
207max_lock_retries = 5
208max_languages = 20
209language_timeout_secs = 30
210brpop_timeout_secs = 5
211git_command_timeout_secs = 120
212
213171[git.ssh]
214172bind_addr = "0.0.0.0"
215173port = 22
@@ -272,64 +230,6 @@
272230enabled = true
273231bind_addr = "0.0.0.0"
274232port = 3001
275
276[health]
277enabled = true
278bind_addr = "0.0.0.0"
279
280[health.synchost]
281port = 3002
282
283[ci]
284enabled = false
285secret_encryption_key = ""
286event_queue_topic = "gitdata:events"
287event_dlq_topic = "gitdata:events:dlq"
288lease_secs = 60
289heartbeat_interval_secs = 15
290long_poll_timeout_secs = 30
291max_attempts = 2
292job_timeout_default_secs = 3600
293job_timeout_max_secs = 21600
294log_chunk_max_bytes = 262144
295log_retention_days = 30
296artifact_retention_days = 30
297run_retention_days = 180
298artifact_max_bytes = 2147483648
299outbound_max_attempts = 5
300outbound_backoff_base_secs = 60
301outbound_timeout_secs = 10
302scheduler_interval_secs = 2
303cancel_grace_secs = 120
304
305[webhook]
306enabled = true
307bind_addr = "0.0.0.0"
308port = 3005
309public_base_url = ""
310
311[runner]
312server_url = "http://127.0.0.1:3005"
313registration_token = ""
314name = ""
315labels = ["self-hosted"]
316capacity = 1
317workspace_root = "/var/lib/gitdata-runner"
318credential_path = "/var/lib/gitdata-runner/credential"
319docker_enabled = false
320default_shell = "sh"
321pat = ""
322git_binary = "git"
323docker_binary = "docker"
324clone_timeout_secs = 1800
325default_job_timeout_secs = 3600
326poll_timeout_secs = 30
327heartbeat_interval_secs = 15
328log_flush_interval_ms = 1000
329log_flush_bytes = 65536
330keep_workspace = false
331once = false
332
333233
334234[push]
335235enabled = false
Modified docs/oauth.md View docs/oauth.md
@@ -55,11 +55,10 @@
5555
56561. 不迁移存量用户、口令、会话、Access Key、space 数据。
57572. 不把 `rooms`、`dir`、目录覆盖、space 图片、`space_warn` 迁入 KenOAuth。
583. 不把仓库、issue、PR、CI、chat 消息迁入 KenOAuth。
583. 不把仓库、issue、PR、chat 消息迁入 KenOAuth。
59594. GitData 已移除 `mails` 服务,非认证邮件(issue/PR/chat/space 邀请)改走 KenOAuth 内部 `EmailService.SendEmail`。
605. 不在本期替换 CI 的 `ci_` job token。
616. 不在本期实现 WebAuthn / SMS 多因子(KenOAuth 表结构已就绪)。
627. 不在本期把 GitData 的资源管理能力迁入 KenAdmin(见 §16 能力缺口)。
605. 不在本期实现 WebAuthn / SMS 多因子(KenOAuth 表结构已就绪)。
616. 不在本期把 GitData 的资源管理能力迁入 KenAdmin(见 §16 能力缺口)。
6362
6463---
6564
@@ -116,10 +115,10 @@
116115└──────────┬───────────────────┘
117116 │ 本地投影:users / spaces / space_members
118117 ▼
119 PostgreSQL(gitdata) + Redis + RustFS + Meilisearch
118 PostgreSQL(gitdata) + Redis + RustFS
120119 ▲
121120 │
122 synchost / searchhost / im / webhook
121 im
123122```
124123
125124### 3.2 令牌流转
@@ -712,7 +711,7 @@
712711
713712### B1 用户 ID 全面 i64(8 人日)
714713
715原则:只改用户标识,不改 `space/dir/repo/issue/room/ci` 等资源主键。
714原则:只改用户标识,不改 `space/dir/repo/issue/room` 等资源主键。
716715
717716model 层:
718717
@@ -903,7 +902,7 @@
9039023. `lib/user`、`lib/auth` 死代码清理。
9049034. `openapi.json` + `web/src/shared/api` 最终生成;`openapi-check` 通过。
9059045. Helm/compose/justfile 最终校验。
9066. 全链路冒烟:注册 → 登录 → 建组织 → 邀请 → 建仓库 → push → issue → chat → CI。
9056. 全链路冒烟:注册 → 登录 → 建组织 → 邀请 → 建仓库 → push → issue → chat。
907906
908907---
909908
@@ -1181,7 +1180,7 @@
11811180| 多邮箱 | 改为单邮箱 |
11821181| 信任设备 | `user_devices` 删除 |
11831182| 手机验证 | `allow_join_level` 的 phone 档位降级为邮箱验证 |
1184| GitData 资源管理 | 删除 adminhost 后,用户/仓库/runner/队列/CI/chat 审核/系统状态均无管理入口;KenAdmin 目前只管自身用户与 KenOAuth |
1183| GitData 资源管理 | 删除 adminhost 后,用户/仓库/chat 审核/系统状态均无管理入口;KenAdmin 目前只管自身用户与 KenOAuth |
11851184| 聊天 JWT | 改为 KenOAuth service token,TTL 900s |
11861185| 邀请统计 | `invite_sort_url_hash` 随表删除 |
11871186
Modified lib/auth/src/handlers/access_keys.rs View lib/auth/src/handlers/access_keys.rs
@@ -212,7 +212,7 @@
212212 let rotated = client
213213 .rotate_access_key(uid, &key_id.to_string(), &rpc::client_ip(&headers))
214214 .await
215 .map_err(|error| ApiError::not_found("access key not found"))?;
215 .map_err(|_error| ApiError::not_found("access key not found"))?;
216216 let key = rotated
217217 .key
218218 .ok_or_else(|| ApiError::internal("kenoauth returned no key"))?;
Modified lib/auth/src/handlers/get_user.rs View lib/auth/src/handlers/get_user.rs
@@ -2,7 +2,6 @@
22use export::rest::{AppResponseData, NoData};
33use jwt::Session;
44use jwt::share::session::require_uid;
5use uuid::Uuid;
65
76use crate::errors::ApiError;
87use crate::services::AuthService;
Modified lib/auth/src/services/preferences.rs View lib/auth/src/services/preferences.rs
@@ -3,7 +3,6 @@
33use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, Set};
44use serde::{Deserialize, Serialize};
55use time::OffsetDateTime;
6use uuid::Uuid;
76
87use super::AuthService;
98use crate::errors::AuthError;
Modified lib/auth/src/services/profile.rs View lib/auth/src/services/profile.rs
@@ -5,7 +5,6 @@
55};
66use serde::{Deserialize, Serialize};
77use time::{Date, OffsetDateTime};
8use uuid::Uuid;
98
109use super::AuthService;
1110use crate::errors::AuthError;
Modified lib/auth/src/share/types.rs View lib/auth/src/share/types.rs
@@ -1,5 +1,4 @@
11use serde::{Deserialize, Serialize};
2use uuid::Uuid;
32
43#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
54pub struct TempKey {
Modified lib/chat/src/handlers/mod.rs View lib/chat/src/handlers/mod.rs
@@ -10,7 +10,6 @@
1010pub mod reminders;
1111pub mod room_admin;
1212pub mod rooms;
13pub mod search;
1413pub mod settings;
1514pub mod threads;
1615pub mod voice;
Modified lib/chat/src/handlers/presence.rs View lib/chat/src/handlers/presence.rs
@@ -2,7 +2,6 @@
22use export::rest::{AppResponseData, NoData};
33use jwt::Session;
44use jwt::share::session::require_uid;
5use uuid::Uuid;
65
76use crate::errors::ApiError;
87use crate::services::presence::{PresenceDto, SetPresenceCmd};
Deleted lib/chat/src/handlers/search.rs
@@ -1,31 +0,0 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use jwt::Session;
4use jwt::share::session::require_uid;
5
6use crate::errors::ApiError;
7use crate::services::search::{SearchPage, SearchQuery};
8use crate::services::service::ChatService;
9
10#[utoipa::path(
11 get,
12 path = "/chat/spaces/{space_name}/messages/search",
13 tag = "chat",
14 description = "在空间消息中执行全文检索,并按权限过滤结果。",
15 params(("space_name" = String, Path, description = "space name"), SearchQuery),
16 responses(
17 (status = 200, description = "search results", body = AppResponseData<SearchPage>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>),
19 (status = 401, description = "authentication required", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn search_messages(
23 State(service): State<ChatService>,
24 session: Session,
25 Path(space_name): Path<String>,
26 Query(q): Query<SearchQuery>,
27) -> Result<AppResponseData<SearchPage>, ApiError> {
28 let uid = require_uid(&session)?;
29 let page = service.search_messages(uid, &space_name, q).await?;
30 Ok(AppResponseData::ok(page))
31}
Modified lib/chat/src/services/messages.rs View lib/chat/src/services/messages.rs
@@ -743,7 +743,6 @@
743743 txn_result?;
744744 pending.flush(&self.state.redis).await;
745745
746 self.queue_search_message(message_id).await;
747746 let row = messages::Entity::find_by_id(message_id)
748747 .one(self.db().read())
749748 .await?
@@ -1116,7 +1115,6 @@
11161115 .await?;
11171116 pending.flush(&self.state.redis).await;
11181117
1119 self.queue_search_message(message_id).await;
11201118 let row = messages::Entity::find_by_id(message_id)
11211119 .one(self.db().read())
11221120 .await?
@@ -1243,9 +1241,6 @@
12431241 })
12441242 .await?;
12451243
1246 for &deleted_id in &row_ids {
1247 self.queue_search_message(deleted_id).await;
1248 }
12491244 let attached = attachment_rows::Entity::find()
12501245 .filter(attachment_rows::Column::MessageId.is_in(row_ids.clone()))
12511246 .all(self.db().read())
Modified lib/chat/src/services/mod.rs View lib/chat/src/services/mod.rs
@@ -12,7 +12,6 @@
1212pub mod reminders;
1313pub mod room_admin;
1414pub mod rooms;
15pub mod search;
1615pub mod service;
1716pub mod settings;
1817pub mod threads;
Modified lib/chat/src/services/rooms.rs View lib/chat/src/services/rooms.rs
@@ -589,12 +589,6 @@
589589 room_am.is_active = Set(false);
590590 room_am.updated_at = Set(now);
591591 room_am.update(self.db().write()).await?;
592 // 房间没有成员后停用,房间级索引任务会先清空它的消息文档。
593 if let Err(error) =
594 search::outbox::enqueue_room_messages(self.db().write(), room.id).await
595 {
596 tracing::warn!(%room_id, error = %error, "search room purge enqueue failed");
597 }
598592 }
599593 }
600594 }
Deleted lib/chat/src/services/search.rs
@@ -1,193 +0,0 @@
1use sea_orm::{DatabaseBackend, FromQueryResult, Statement};
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4use utoipa::{IntoParams, ToSchema};
5use uuid::Uuid;
6
7use crate::errors::ChatError;
8use crate::services::service::ChatService;
9use crate::share::lookup::{lookup_space_by_name, require_space_member};
10
11#[derive(Debug, Clone, Serialize, ToSchema)]
12pub struct SearchHitDto {
13 pub message_id: Uuid,
14 pub room_id: Uuid,
15 pub author_id: i64,
16 pub author: Option<space::services::spaces::UserDto>,
17 pub seq: i64,
18 pub content: String,
19 pub created_at: OffsetDateTime,
20}
21
22#[derive(Debug, Clone, Serialize, ToSchema)]
23pub struct SearchPage {
24 pub items: Vec<SearchHitDto>,
25 pub total: i64,
26}
27
28#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
29pub struct SearchQuery {
30 pub q: String,
31 pub room_id: Option<Uuid>,
32 pub author_id: Option<i64>,
33 pub limit: Option<u64>,
34 pub offset: Option<u64>,
35}
36
37#[derive(Debug, FromQueryResult)]
38struct SearchRow {
39 message_id: Uuid,
40 room_id: Uuid,
41 author_id: i64,
42 seq: i64,
43 content: String,
44 created_at: OffsetDateTime,
45}
46
47#[derive(Debug, FromQueryResult)]
48struct CountRow {
49 total: i64,
50}
51
52impl ChatService {
53 pub async fn search_messages(
54 &self,
55 caller: i64,
56 space_name: &str,
57 q: SearchQuery,
58 ) -> Result<SearchPage, ChatError> {
59 let space = lookup_space_by_name(self, space_name).await?;
60 require_space_member(self, &space, caller).await?;
61
62 let query = q.q.trim();
63 if query.is_empty() {
64 return Err(ChatError::BadRequest("query is empty".to_string()));
65 }
66 let limit = q
67 .limit
68 .unwrap_or(self.config().chat.default_pagination)
69 .clamp(1, self.config().chat.max_pagination);
70 let offset = q
71 .offset
72 .unwrap_or(0)
73 .clamp(0, export::pagination::MAX_OFFSET);
74
75 // Private rooms must be restricted to their members; public space
76 // rooms are readable by every space member. CJK text is not tokenized
77 // by Postgres FTS ('simple' config), so CJK queries fall back to
78 // substring ILIKE matching.
79 let cjk = query.chars().any(is_cjk_char);
80 let text_predicate = if cjk {
81 "AND m.content ILIKE $8"
82 } else {
83 "AND m.search_vector @@ plainto_tsquery('simple', $2)"
84 };
85 let order_clause = if cjk {
86 "ORDER BY m.created_at DESC"
87 } else {
88 "ORDER BY ts_rank(m.search_vector, plainto_tsquery('simple', $2)) DESC, m.created_at DESC"
89 };
90 let like_pattern = format!("%{}%", escape_like(query));
91 let filters = format!(
92 r#"
93 AND r.space_id = $1
94 AND m.deleted_at IS NULL
95 {text_predicate}
96 AND ($5::uuid IS NULL OR m.room_id = $5)
97 AND ($6::uuid IS NULL OR m.author_id = $6)
98 AND (r.is_private = false OR r.owner_id = $7
99 OR EXISTS (SELECT 1 FROM room_members rm
100 WHERE rm.room_id = m.room_id AND rm.user_id = $7
101 AND rm.left_at IS NULL))"#
102 );
103
104 let count_sql = format!(
105 "SELECT count(*)::bigint AS total FROM chat_message m JOIN rooms r ON r.id = m.room_id WHERE 1=1{filters}"
106 );
107 let count_row = CountRow::find_by_statement(Statement::from_sql_and_values(
108 DatabaseBackend::Postgres,
109 &count_sql,
110 [
111 space.id.into(),
112 query.into(),
113 0i64.into(),
114 0i64.into(),
115 q.room_id.into(),
116 q.author_id.into(),
117 caller.into(),
118 like_pattern.clone().into(),
119 ],
120 ))
121 .one(self.db().read())
122 .await?
123 .ok_or(ChatError::Internal("count query returned no row".into()))?;
124
125 let sql = format!(
126 r#"SELECT m.id AS message_id, m.room_id, m.author_id, m.seq, m.content, m.created_at
127 FROM chat_message m
128 JOIN rooms r ON r.id = m.room_id
129 WHERE 1=1{filters}
130 {order_clause}
131 LIMIT $3 OFFSET $4"#
132 );
133 let rows = SearchRow::find_by_statement(Statement::from_sql_and_values(
134 DatabaseBackend::Postgres,
135 &sql,
136 [
137 space.id.into(),
138 query.into(),
139 (limit as i64).into(),
140 (offset as i64).into(),
141 q.room_id.into(),
142 q.author_id.into(),
143 caller.into(),
144 like_pattern.into(),
145 ],
146 ))
147 .all(self.db().read())
148 .await?;
149
150 let author_ids: Vec<i64> = rows.iter().map(|r| r.author_id).collect();
151 let author_map = self.load_user_briefs(&author_ids).await?;
152 let items = rows
153 .into_iter()
154 .map(|r| SearchHitDto {
155 author: author_map.get(&r.author_id).cloned(),
156 message_id: r.message_id,
157 room_id: r.room_id,
158 author_id: r.author_id,
159 seq: r.seq,
160 content: r.content,
161 created_at: r.created_at,
162 })
163 .collect();
164 Ok(SearchPage {
165 items,
166 total: count_row.total,
167 })
168 }
169}
170
171fn is_cjk_char(c: char) -> bool {
172 matches!(c as u32,
173 0x4E00..=0x9FFF
174 | 0x3400..=0x4DBF
175 | 0x20000..=0x2A6DF
176 | 0xF900..=0xFAFF
177 | 0x3040..=0x30FF
178 | 0x31F0..=0x31FF
179 | 0xAC00..=0xD7AF
180 | 0x1100..=0x11FF
181 | 0x3130..=0x318F)
182}
183
184fn escape_like(q: &str) -> String {
185 let mut out = String::with_capacity(q.len());
186 for c in q.chars() {
187 if matches!(c, '%' | '_' | '\\') {
188 out.push('\\');
189 }
190 out.push(c);
191 }
192 out
193}
Modified lib/chat/src/services/service.rs View lib/chat/src/services/service.rs
@@ -8,7 +8,6 @@
88use models::users::user;
99use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
1010use space::services::spaces::UserDto;
11use uuid::Uuid;
1211
1312use crate::errors::ChatError;
1413use crate::share::realtime::Realtime;
@@ -80,14 +79,6 @@
8079 .map_err(ChatError::from)
8180 }
8281
83 /// 消息落库后的搜索增量同步。检索组件不可用只是"搜不到",绝不回滚消息写入,
84 /// 所以这里只记日志。
85 pub(crate) async fn queue_search_message(&self, message_id: Uuid) {
86 if let Err(error) = search::outbox::enqueue_message(self.db().write(), message_id).await {
87 tracing::warn!(%message_id, error = %error, "search message enqueue failed");
88 }
89 }
90
9182 pub(crate) async fn send_email(
9283 &self,
9384 to: &str,
@@ -105,4 +96,3 @@
10596 .map_err(|error| ChatError::Internal(format!("email send failed: {error}")))
10697 }
10798}
108
Modified lib/chat/src/services/webhooks.rs View lib/chat/src/services/webhooks.rs
@@ -403,7 +403,6 @@
403403 })
404404 .await?;
405405
406 self.queue_search_message(message_id).await;
407406 let row = chat_messages::Entity::find_by_id(message_id)
408407 .one(self.db().read())
409408 .await?
Modified lib/chat/src/share/authz.rs View lib/chat/src/share/authz.rs
@@ -1,6 +1,5 @@
11use models::space::spaces;
22use space::share::authz::{MANAGE_CHANNELS, effective_permissions};
3use uuid::Uuid;
43
54use crate::errors::ChatError;
65use crate::services::service::ChatService;
Modified lib/chat/src/openapi.rs View lib/chat/src/openapi.rs
@@ -76,7 +76,6 @@
7676 crate::handlers::presence::set_presence,
7777 crate::handlers::presence::get_presence,
7878 crate::handlers::presence::list_space_presences,
79 crate::handlers::search::search_messages,
8079 crate::handlers::settings::get_room_setting,
8180 crate::handlers::settings::update_room_setting,
8281 crate::handlers::webhooks::create_webhook,
@@ -197,9 +196,6 @@
197196 crate::services::voice::VoiceTileDto,
198197 crate::services::voice::HandDto,
199198 crate::services::voice::HandState,
200 crate::services::search::SearchHitDto,
201 crate::services::search::SearchPage,
202 crate::services::search::SearchQuery,
203199 crate::services::settings::RoomSettingDto,
204200 crate::services::settings::UpdateRoomSettingCmd,
205201 crate::services::webhooks::UpdateWebhookCmd,
Modified lib/chat/src/routes.rs View lib/chat/src/routes.rs
@@ -24,7 +24,6 @@
2424 accept_room_invite, add_room_members, create_room_invite, get_room, join_room, leave_room,
2525 list_room_members, list_space_rooms, reject_room_invite, update_room,
2626};
27use crate::handlers::search::search_messages;
2827use crate::handlers::settings::{get_room_setting, update_room_setting};
2928use crate::handlers::threads::{
3029 create_thread, follow_thread, get_thread, list_my_threads, list_threads, unfollow_thread,
@@ -213,10 +212,6 @@
213212 .route(
214213 "/chat/spaces/{space_name}/presences",
215214 get(list_space_presences),
216 )
217 .route(
218 "/chat/spaces/{space_name}/messages/search",
219 get(search_messages),
220215 )
221216 .route(
222217 "/chat/rooms/{room_id}/settings",
Modified lib/chat/Cargo.toml View lib/chat/Cargo.toml
@@ -10,7 +10,6 @@
1010i18n = { workspace = true }
1111imaging = { workspace = true }
1212models = { workspace = true }
13search = { workspace = true }
1413social = { workspace = true }
1514jwt = { workspace = true }
1615kenauth = { workspace = true }
Deleted lib/ci/src/handlers/actions.rs
@@ -1,242 +0,0 @@
1use axum::{
2 Json,
3 extract::{Path, State},
4 response::Redirect,
5};
6use export::rest::AppResponseData;
7use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
8use jwt::{Session, SessionUser};
9use uuid::Uuid;
10
11use crate::{
12 CiService,
13 actions_environments::{ApprovalDto, EnvironmentRules, RepoEnvironmentDto},
14 errors::{ApiError, CiError},
15};
16
17#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)]
18pub struct ReviewCmd {
19 pub approved: bool,
20 pub comment: Option<String>,
21}
22
23#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
24pub struct VariableDto {
25 pub name: String,
26 pub value: String,
27}
28
29#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)]
30pub struct VariableCmd {
31 pub value: String,
32}
33
34#[utoipa::path(post,path="/repos/{space_name}/{repo_name}/pipelines/dispatches",tag="pipelines",params(("space_name"=String,Path),("repo_name"=String,Path)),request_body=crate::actions_events::DispatchCmd,responses((status=200,body=AppResponseData<Vec<Uuid>>)))]
35pub async fn dispatch(
36 State(service): State<CiService>,
37 session: Session,
38 Path((space, repo)): Path<(String, String)>,
39 Json(request): Json<crate::actions_events::DispatchCmd>,
40) -> Result<AppResponseData<Vec<Uuid>>, ApiError> {
41 let (repo, space) = service.resolve_repo(&space, &repo).await?;
42 let caller = service.require_write(&session, &repo).await?;
43 Ok(AppResponseData::ok(
44 service
45 .repository_dispatch(&repo, &space, caller, &request)
46 .await?,
47 ))
48}
49
50#[utoipa::path(get,path="/repos/{space_name}/{repo_name}/environments",tag="pipelines",params(("space_name"=String,Path),("repo_name"=String,Path)),responses((status=200,body=AppResponseData<Vec<RepoEnvironmentDto>>)))]
51pub async fn environments(
52 State(service): State<CiService>,
53 session: Session,
54 Path((space, repo)): Path<(String, String)>,
55) -> Result<AppResponseData<Vec<RepoEnvironmentDto>>, ApiError> {
56 let (repo, _) = service.resolve_repo(&space, &repo).await?;
57 service.require_read(&session, &repo).await?;
58 Ok(AppResponseData::ok(
59 service.repo_actions_environments(repo.id).await?,
60 ))
61}
62
63#[utoipa::path(put,path="/repos/{space_name}/{repo_name}/environments/{name}",tag="pipelines",params(("space_name"=String,Path),("repo_name"=String,Path),("name"=String,Path)),request_body=EnvironmentRules,responses((status=200,body=AppResponseData<RepoEnvironmentDto>)))]
64pub async fn upsert_environment(
65 State(service): State<CiService>,
66 session: Session,
67 Path((space, repo, name)): Path<(String, String, String)>,
68 Json(rules): Json<EnvironmentRules>,
69) -> Result<AppResponseData<RepoEnvironmentDto>, ApiError> {
70 let (repo, space) = service.resolve_repo(&space, &repo).await?;
71 let caller = session.uid().ok_or(CiError::Unauthorized)?;
72 let context = service.git_context(&repo).await?;
73 git::share::access::authorize_repo_access(
74 &git::service::GitService::new(service.state.clone()),
75 caller,
76 &context,
77 true,
78 true,
79 )
80 .await
81 .map_err(CiError::from)?;
82 Ok(AppResponseData::ok(
83 service
84 .upsert_actions_environment(space.id, repo.id, &name, &rules)
85 .await?,
86 ))
87}
88
89#[utoipa::path(get,path="/repos/{space_name}/{repo_name}/pipelines/{run_id}/approvals",tag="pipelines",params(("space_name"=String,Path),("repo_name"=String,Path),("run_id"=Uuid,Path)),responses((status=200,body=AppResponseData<Vec<ApprovalDto>>)))]
90pub async fn approvals(
91 State(service): State<CiService>,
92 session: Session,
93 Path((space, repo, run)): Path<(String, String, Uuid)>,
94) -> Result<AppResponseData<Vec<ApprovalDto>>, ApiError> {
95 let (repo, _) = service.resolve_repo(&space, &repo).await?;
96 service.require_read(&session, &repo).await?;
97 Ok(AppResponseData::ok(
98 service.actions_approvals(repo.id, run).await?,
99 ))
100}
101
102#[utoipa::path(post,path="/repos/{space_name}/{repo_name}/pipelines/{run_id}/approvals/{review_id}",tag="pipelines",params(("space_name"=String,Path),("repo_name"=String,Path),("run_id"=Uuid,Path),("review_id"=Uuid,Path)),request_body=ReviewCmd,responses((status=200,body=AppResponseData<Uuid>)))]
103pub async fn review(
104 State(service): State<CiService>,
105 session: Session,
106 Path((space, repo, run, review)): Path<(String, String, Uuid, Uuid)>,
107 Json(request): Json<ReviewCmd>,
108) -> Result<AppResponseData<Uuid>, ApiError> {
109 let (repo, _) = service.resolve_repo(&space, &repo).await?;
110 let caller = service
111 .require_read(&session, &repo)
112 .await?
113 .ok_or(CiError::Unauthorized)?;
114 service
115 .review_actions_environment(
116 repo.id,
117 run,
118 review,
119 caller,
120 request.approved,
121 request.comment,
122 )
123 .await?;
124 Ok(AppResponseData::ok(review))
125}
126
127#[utoipa::path(get,path="/repos/{space_name}/{repo_name}/variables",tag="pipelines",params(("space_name"=String,Path),("repo_name"=String,Path)),responses((status=200,body=AppResponseData<Vec<VariableDto>>)))]
128pub async fn variables(
129 State(service): State<CiService>,
130 session: Session,
131 Path((space, repo)): Path<(String, String)>,
132) -> Result<AppResponseData<Vec<VariableDto>>, ApiError> {
133 let (repo, _) = service.resolve_repo(&space, &repo).await?;
134 service.require_read(&session, &repo).await?;
135 Ok(AppResponseData::ok(
136 service
137 .actions_variables(repo.id)
138 .await?
139 .into_iter()
140 .map(|(name, value)| VariableDto { name, value })
141 .collect(),
142 ))
143}
144
145#[utoipa::path(put,path="/repos/{space_name}/{repo_name}/variables/{name}",tag="pipelines",params(("space_name"=String,Path),("repo_name"=String,Path),("name"=String,Path)),request_body=VariableCmd,responses((status=200,body=AppResponseData<VariableDto>)))]
146pub async fn upsert_variable(
147 State(service): State<CiService>,
148 session: Session,
149 Path((space, repo, name)): Path<(String, String, String)>,
150 Json(request): Json<VariableCmd>,
151) -> Result<AppResponseData<VariableDto>, ApiError> {
152 let (repo, _) = service.resolve_repo(&space, &repo).await?;
153 service.require_write(&session, &repo).await?;
154 let name = name.trim().to_uppercase();
155 if name.is_empty()
156 || name.len() > 128
157 || !name
158 .chars()
159 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
160 || request.value.len() > 48 * 1024
161 {
162 return Err(CiError::InvalidArgument("invalid variable name or value".into()).into());
163 }
164 let row = models::ci::variable::Entity::find()
165 .filter(models::ci::variable::Column::RepoId.eq(repo.id))
166 .filter(models::ci::variable::Column::Name.eq(&name))
167 .one(service.db_write())
168 .await
169 .map_err(CiError::from)?;
170 if let Some(row) = row {
171 let mut active: models::ci::variable::ActiveModel = row.into();
172 active.value = Set(request.value.clone());
173 active.updated_at = Set(time::OffsetDateTime::now_utc());
174 active
175 .update(service.db_write())
176 .await
177 .map_err(CiError::from)?;
178 } else {
179 models::ci::variable::ActiveModel {
180 id: Set(Uuid::now_v7()),
181 repo_id: Set(repo.id),
182 name: Set(name.clone()),
183 value: Set(request.value.clone()),
184 updated_at: Set(time::OffsetDateTime::now_utc()),
185 }
186 .insert(service.db_write())
187 .await
188 .map_err(CiError::from)?;
189 }
190 Ok(AppResponseData::ok(VariableDto {
191 name,
192 value: request.value,
193 }))
194}
195
196#[utoipa::path(delete,path="/repos/{space_name}/{repo_name}/variables/{name}",tag="pipelines",params(("space_name"=String,Path),("repo_name"=String,Path),("name"=String,Path)),responses((status=200,body=AppResponseData<String>)))]
197pub async fn delete_variable(
198 State(service): State<CiService>,
199 session: Session,
200 Path((space, repo, name)): Path<(String, String, String)>,
201) -> Result<AppResponseData<String>, ApiError> {
202 let (repo, _) = service.resolve_repo(&space, &repo).await?;
203 service.require_write(&session, &repo).await?;
204 models::ci::variable::Entity::delete_many()
205 .filter(models::ci::variable::Column::RepoId.eq(repo.id))
206 .filter(models::ci::variable::Column::Name.eq(name.to_uppercase()))
207 .exec(service.db_write())
208 .await
209 .map_err(CiError::from)?;
210 Ok(AppResponseData::ok(name))
211}
212
213#[utoipa::path(get,path="/repos/{space_name}/{repo_name}/pipelines/actions-artifacts/{artifact_id}/download",tag="pipelines",params(("space_name"=String,Path),("repo_name"=String,Path),("artifact_id"=i64,Path)),responses((status=307,description="artifact download")))]
214pub async fn download_actions_artifact(
215 State(service): State<CiService>,
216 session: Session,
217 Path((space, repo, id)): Path<(String, String, i64)>,
218) -> Result<Redirect, ApiError> {
219 let (repo, _) = service.resolve_repo(&space, &repo).await?;
220 service.require_read(&session, &repo).await?;
221 let identity = models::ci::actions_identity::Entity::find_by_id(id)
222 .one(service.db_read())
223 .await
224 .map_err(CiError::from)?
225 .ok_or(CiError::NotFound)?;
226 let artifact = models::ci::workflow_artifact::Entity::find_by_id(identity.entity_id)
227 .one(service.db_read())
228 .await
229 .map_err(CiError::from)?
230 .ok_or(CiError::NotFound)?;
231 let run = models::ci::workflow_run::Entity::find_by_id(artifact.run_id)
232 .one(service.db_read())
233 .await
234 .map_err(CiError::from)?
235 .ok_or(CiError::NotFound)?;
236 if run.repo_id != repo.id {
237 return Err(ApiError::not_found());
238 }
239 Ok(Redirect::temporary(
240 &service.artifact_download_url(artifact.id).await?,
241 ))
242}
Deleted lib/ci/src/handlers/artifacts.rs
@@ -1,62 +0,0 @@
1use axum::extract::{Path, State};
2use axum::response::Redirect;
3use export::rest::AppResponseData;
4use jwt::Session;
5
6use crate::errors::ApiError;
7use crate::handlers::runs::RepoPath;
8use crate::service::CiService;
9use crate::triggers::ArtifactDto;
10
11#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
12pub struct ArtifactListDto {
13 pub artifacts: Vec<ArtifactDto>,
14}
15
16#[utoipa::path(
17 get,
18 path = "/repos/{space_name}/{repo_name}/pipelines/{run_id}/artifacts",
19 tag = "pipelines",
20 description = "列出一次运行的全部产物。",
21 params(RepoPath, ("run_id" = uuid::Uuid, Path)),
22 responses((status = 200, description = "artifacts", body = AppResponseData<ArtifactListDto>))
23)]
24pub async fn list_artifacts(
25 State(service): State<CiService>,
26 session: Session,
27 Path((space_name, repo_name, run_id)): Path<(String, String, uuid::Uuid)>,
28) -> Result<AppResponseData<ArtifactListDto>, ApiError> {
29 let (repo, _space) = service.resolve_repo(&space_name, &repo_name).await?;
30 service.require_read(&session, &repo).await?;
31 let detail = service.run_detail(run_id).await?;
32 if detail.run.repo_id != repo.id {
33 return Err(ApiError::not_found());
34 }
35 Ok(AppResponseData::ok(ArtifactListDto {
36 artifacts: detail.artifacts,
37 }))
38}
39
40#[utoipa::path(
41 get,
42 path = "/repos/{space_name}/{repo_name}/pipelines/artifacts/{artifact_id}/download",
43 tag = "pipelines",
44 description = "重定向到产物的预签名下载地址。",
45 params(RepoPath, ("artifact_id" = uuid::Uuid, Path)),
46 responses((status = 302, description = "redirect to object storage"))
47)]
48pub async fn download_artifact(
49 State(service): State<CiService>,
50 session: Session,
51 Path((space_name, repo_name, artifact_id)): Path<(String, String, uuid::Uuid)>,
52) -> Result<Redirect, ApiError> {
53 let (repo, _space) = service.resolve_repo(&space_name, &repo_name).await?;
54 service.require_read(&session, &repo).await?;
55 let artifact = service.artifact_row(artifact_id).await?;
56 let detail = service.run_detail(artifact.run_id).await?;
57 if detail.run.repo_id != repo.id {
58 return Err(ApiError::not_found());
59 }
60 let url = service.artifact_download_url(artifact_id).await?;
61 Ok(Redirect::temporary(&url))
62}
Deleted lib/ci/src/handlers/logs.rs
@@ -1,45 +0,0 @@
1use axum::extract::{Path, Query, State};
2use export::rest::AppResponseData;
3use jwt::Session;
4use utoipa::IntoParams;
5
6use crate::errors::ApiError;
7use crate::handlers::runs::RepoPath;
8use crate::logs::LogPageDto;
9use crate::service::CiService;
10
11#[derive(Debug, Clone, serde::Deserialize, IntoParams)]
12pub struct LogQuery {
13 pub after_seq: Option<i32>,
14 pub limit: Option<u64>,
15}
16
17#[utoipa::path(
18 get,
19 path = "/repos/{space_name}/{repo_name}/pipelines/{run_id}/jobs/{job_id}/logs",
20 tag = "pipelines",
21 description = "增量读取作业日志块(按 seq 递增)。",
22 params(RepoPath, ("run_id" = uuid::Uuid, Path), ("job_id" = uuid::Uuid, Path), LogQuery),
23 responses((status = 200, description = "job logs", body = AppResponseData<LogPageDto>))
24)]
25pub async fn read_logs(
26 State(service): State<CiService>,
27 session: Session,
28 Path((space_name, repo_name, run_id, job_id)): Path<(String, String, uuid::Uuid, uuid::Uuid)>,
29 Query(query): Query<LogQuery>,
30) -> Result<AppResponseData<LogPageDto>, ApiError> {
31 let (repo, _space) = service.resolve_repo(&space_name, &repo_name).await?;
32 service.require_read(&session, &repo).await?;
33 let job = service.job_row(job_id).await?;
34 if job.run_id != run_id || job.repo_id != repo.id {
35 return Err(ApiError::not_found());
36 }
37 let page = service
38 .read_job_logs(
39 job_id,
40 query.after_seq.unwrap_or(0),
41 query.limit.unwrap_or(50),
42 )
43 .await?;
44 Ok(AppResponseData::ok(page))
45}
Deleted lib/ci/src/handlers/mod.rs
@@ -1,6 +0,0 @@
1pub mod actions;
2pub mod artifacts;
3pub mod logs;
4pub mod runner_protocol;
5pub mod runs;
6pub mod space;
Deleted lib/ci/src/handlers/runner_protocol.rs
@@ -1,279 +0,0 @@
1use axum::Json;
2use axum::extract::{Path, State};
3use export::rest::AppResponseData;
4use uuid::Uuid;
5
6use ci_protocol::runner::{
7 ArtifactCompleteRequest, ArtifactInitRequest, ArtifactInitResponse, ClaimRequest,
8 HeartbeatRequest, HeartbeatResponse, JobFinishRequest, JobPayload, LogChunksRequest,
9 RegisterRequest, RegisterResponse, StepReport,
10};
11
12use crate::errors::ApiError;
13use crate::runners::RunnerAuth;
14use crate::service::CiService;
15
16#[utoipa::path(
17 post,
18 path = "/runner/register",
19 tag = "runner",
20 description = "使用注册令牌注册一个 runner,返回专属凭据。",
21 request_body = RegisterRequest,
22 responses((status = 200, description = "runner registered", body = AppResponseData<RegisterResponse>))
23)]
24pub async fn register(
25 State(service): State<CiService>,
26 Json(request): Json<RegisterRequest>,
27) -> Result<AppResponseData<RegisterResponse>, ApiError> {
28 let mut response = service
29 .register_runner(
30 &request.token,
31 &request.name,
32 request.labels,
33 request.capacity,
34 request.version,
35 )
36 .await?;
37 response.protocol_version = service
38 .configure_runner_protocol(
39 response.runner_id,
40 &request.protocol_versions,
41 &request.capabilities,
42 )
43 .await?;
44 Ok(AppResponseData::ok(response))
45}
46
47#[utoipa::path(
48 post,
49 path = "/runner/heartbeat",
50 tag = "runner",
51 description = "runner 心跳,同时下发需要终止的作业 id。",
52 request_body = HeartbeatRequest,
53 responses((status = 200, description = "heartbeat accepted", body = AppResponseData<HeartbeatResponse>))
54)]
55pub async fn heartbeat(
56 State(service): State<CiService>,
57 auth: RunnerAuth,
58 Json(request): Json<HeartbeatRequest>,
59) -> Result<AppResponseData<HeartbeatResponse>, ApiError> {
60 let cancel = service.runner_heartbeat(auth.runner_id).await?;
61 if !request.protocol_versions.is_empty() {
62 service
63 .configure_runner_protocol(
64 auth.runner_id,
65 &request.protocol_versions,
66 &request.capabilities,
67 )
68 .await?;
69 }
70 Ok(AppResponseData::ok(HeartbeatResponse {
71 cancel,
72 next_heartbeat_secs: service.config().ci.heartbeat_interval_secs,
73 }))
74}
75
76#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
77pub struct ClaimEnvelope {
78 #[serde(default)]
79 pub job: Option<JobPayload>,
80}
81
82#[utoipa::path(
83 post,
84 path = "/runner/jobs/claim",
85 tag = "runner",
86 description = "长轮询领取一个匹配标签的作业。",
87 request_body = ClaimRequest,
88 responses((status = 200, description = "claim result", body = AppResponseData<ClaimEnvelope>))
89)]
90pub async fn claim(
91 State(service): State<CiService>,
92 auth: RunnerAuth,
93 Json(request): Json<ClaimRequest>,
94) -> Result<AppResponseData<ClaimEnvelope>, ApiError> {
95 let wait = request
96 .wait_secs
97 .unwrap_or_else(|| service.config().ci.long_poll_timeout_secs)
98 .min(service.config().ci.long_poll_timeout_secs.max(1) * 2);
99 let labels = service.runner_labels(auth.runner_id).await?;
100 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(wait.max(1));
101 loop {
102 if let Some(job_id) = service.claim_job(auth.runner_id, &labels).await?
103 && let Some(payload) = service.claim_payload(job_id, auth.runner_id).await?
104 {
105 return Ok(AppResponseData::ok(ClaimEnvelope { job: Some(payload) }));
106 }
107 if std::time::Instant::now() >= deadline {
108 return Ok(AppResponseData::ok(ClaimEnvelope { job: None }));
109 }
110 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
111 }
112}
113
114#[utoipa::path(
115 post,
116 path = "/runner/jobs/{job_id}/start",
117 tag = "runner",
118 description = "runner 声明开始执行作业。",
119 params(("job_id" = Uuid, Path)),
120 responses((status = 200, description = "started", body = AppResponseData<Uuid>))
121)]
122pub async fn start(
123 State(service): State<CiService>,
124 auth: RunnerAuth,
125 Path(job_id): Path<Uuid>,
126) -> Result<AppResponseData<Uuid>, ApiError> {
127 let run_id = service.start_job(job_id, auth.runner_id).await?;
128 Ok(AppResponseData::ok(run_id))
129}
130
131#[utoipa::path(
132 post,
133 path = "/runner/jobs/{job_id}/steps/{index}",
134 tag = "runner",
135 description = "上报某个步骤的开始或结束状态。",
136 params(("job_id" = Uuid, Path), ("index" = i32, Path)),
137 request_body = StepReport,
138 responses((status = 200, description = "step recorded", body = AppResponseData<Uuid>))
139)]
140pub async fn report_step(
141 State(service): State<CiService>,
142 auth: RunnerAuth,
143 Path((job_id, index)): Path<(Uuid, i32)>,
144 Json(report): Json<StepReport>,
145) -> Result<AppResponseData<Uuid>, ApiError> {
146 service
147 .report_step(job_id, auth.runner_id, index, &report)
148 .await?;
149 Ok(AppResponseData::ok(job_id))
150}
151
152#[utoipa::path(
153 post,
154 path = "/runner/jobs/{job_id}/logs",
155 tag = "runner",
156 description = "批量上报日志块。",
157 params(("job_id" = Uuid, Path)),
158 request_body = LogChunksRequest,
159 responses((status = 200, description = "logs stored", body = AppResponseData<Uuid>))
160)]
161pub async fn upload_logs(
162 State(service): State<CiService>,
163 auth: RunnerAuth,
164 Path(job_id): Path<Uuid>,
165 Json(request): Json<LogChunksRequest>,
166) -> Result<AppResponseData<Uuid>, ApiError> {
167 service
168 .append_log_chunks(job_id, auth.runner_id, &request.chunks)
169 .await?;
170 Ok(AppResponseData::ok(job_id))
171}
172
173#[utoipa::path(
174 post,
175 path = "/runner/jobs/{job_id}/finish",
176 tag = "runner",
177 description = "上报作业终态(幂等)。",
178 params(("job_id" = Uuid, Path)),
179 request_body = JobFinishRequest,
180 responses((status = 200, description = "job finished", body = AppResponseData<Uuid>))
181)]
182pub async fn finish(
183 State(service): State<CiService>,
184 auth: RunnerAuth,
185 Path(job_id): Path<Uuid>,
186 Json(request): Json<JobFinishRequest>,
187) -> Result<AppResponseData<Uuid>, ApiError> {
188 let run_id = service.finish_job(job_id, auth.runner_id, &request).await?;
189 service.advance_jobs().await?;
190 service.finalize_runs().await?;
191 Ok(AppResponseData::ok(run_id))
192}
193
194#[utoipa::path(post, path="/runner/jobs/{job_id}/events", tag="runner", params(("job_id"=Uuid,Path)), request_body=ci_protocol::actions::EngineEvent, responses((status=200,body=AppResponseData<Uuid>)))]
195pub async fn event(
196 State(service): State<CiService>,
197 auth: RunnerAuth,
198 Path(job_id): Path<Uuid>,
199 Json(request): Json<ci_protocol::actions::EngineEvent>,
200) -> Result<AppResponseData<Uuid>, ApiError> {
201 service
202 .report_actions_event(job_id, auth.runner_id, &request)
203 .await?;
204 Ok(AppResponseData::ok(job_id))
205}
206
207#[utoipa::path(
208 post,
209 path = "/runner/jobs/{job_id}/renew",
210 tag = "runner",
211 description = "续租当前作业。",
212 params(("job_id" = Uuid, Path)),
213 responses((status = 200, description = "lease renewed", body = AppResponseData<Uuid>))
214)]
215pub async fn renew(
216 State(service): State<CiService>,
217 auth: RunnerAuth,
218 Path(job_id): Path<Uuid>,
219) -> Result<AppResponseData<Uuid>, ApiError> {
220 service.renew_lease(job_id, auth.runner_id).await?;
221 Ok(AppResponseData::ok(job_id))
222}
223
224#[utoipa::path(
225 post,
226 path = "/runner/jobs/{job_id}/artifacts/init",
227 tag = "runner",
228 description = "申请产物上传地址。",
229 params(("job_id" = Uuid, Path)),
230 request_body = ArtifactInitRequest,
231 responses((status = 200, description = "artifact initialized", body = AppResponseData<ArtifactInitResponse>))
232)]
233pub async fn init_artifact(
234 State(service): State<CiService>,
235 auth: RunnerAuth,
236 Path(job_id): Path<Uuid>,
237 Json(request): Json<ArtifactInitRequest>,
238) -> Result<AppResponseData<ArtifactInitResponse>, ApiError> {
239 let (artifact_id, upload_url) = service
240 .init_artifact(
241 job_id,
242 auth.runner_id,
243 &request.name,
244 request.size,
245 request.sha256,
246 )
247 .await?;
248 Ok(AppResponseData::ok(ArtifactInitResponse {
249 artifact_id,
250 upload_url,
251 }))
252}
253
254#[utoipa::path(
255 post,
256 path = "/runner/jobs/{job_id}/artifacts/{artifact_id}/complete",
257 tag = "runner",
258 description = "确认产物已上传。",
259 params(("job_id" = Uuid, Path), ("artifact_id" = Uuid, Path)),
260 request_body = ArtifactCompleteRequest,
261 responses((status = 200, description = "artifact completed", body = AppResponseData<Uuid>))
262)]
263pub async fn complete_artifact(
264 State(service): State<CiService>,
265 auth: RunnerAuth,
266 Path((job_id, artifact_id)): Path<(Uuid, Uuid)>,
267 Json(request): Json<ArtifactCompleteRequest>,
268) -> Result<AppResponseData<Uuid>, ApiError> {
269 service
270 .complete_artifact(
271 job_id,
272 auth.runner_id,
273 artifact_id,
274 request.size,
275 request.sha256,
276 )
277 .await?;
278 Ok(AppResponseData::ok(artifact_id))
279}
Deleted lib/ci/src/handlers/runs.rs
@@ -1,171 +0,0 @@
1use axum::Json;
2use axum::extract::{Path, Query, State};
3use export::rest::AppResponseData;
4use jwt::Session;
5use utoipa::IntoParams;
6
7use crate::errors::ApiError;
8use crate::handlers::space::WorkflowSummaryList;
9use crate::service::CiService;
10use crate::triggers::{ListRunsQuery, RunDetailDto, RunPageDto, TriggerRunCmd};
11
12#[derive(Debug, Clone, serde::Deserialize, IntoParams)]
13pub struct RepoPath {
14 pub space_name: String,
15 pub repo_name: String,
16}
17
18#[utoipa::path(
19 get,
20 path = "/repos/{space_name}/{repo_name}/pipelines",
21 tag = "pipelines",
22 description = "分页读取仓库的流水线运行记录。",
23 params(RepoPath, ListRunsQuery),
24 responses((status = 200, description = "pipeline runs", body = AppResponseData<RunPageDto>))
25)]
26pub async fn list_runs(
27 State(service): State<CiService>,
28 session: Session,
29 Path(path): Path<RepoPath>,
30 Query(query): Query<ListRunsQuery>,
31) -> Result<AppResponseData<RunPageDto>, ApiError> {
32 let (repo, _space) = service
33 .resolve_repo(&path.space_name, &path.repo_name)
34 .await?;
35 service.require_read(&session, &repo).await?;
36 let page = service.list_runs(&repo, &query).await?;
37 Ok(AppResponseData::ok(page))
38}
39
40#[utoipa::path(
41 get,
42 path = "/repos/{space_name}/{repo_name}/pipelines/{run_id}",
43 tag = "pipelines",
44 description = "读取流水线运行详情(含 job、step 与产物)。",
45 params(RepoPath, ("run_id" = uuid::Uuid, Path)),
46 responses((status = 200, description = "pipeline run detail", body = AppResponseData<RunDetailDto>))
47)]
48pub async fn get_run(
49 State(service): State<CiService>,
50 session: Session,
51 Path((space_name, repo_name, run_id)): Path<(String, String, uuid::Uuid)>,
52) -> Result<AppResponseData<RunDetailDto>, ApiError> {
53 let (repo, _space) = service.resolve_repo(&space_name, &repo_name).await?;
54 service.require_read(&session, &repo).await?;
55 let detail = service.run_detail(run_id).await?;
56 if detail.run.repo_id != repo.id {
57 return Err(ApiError::not_found());
58 }
59 Ok(AppResponseData::ok(detail))
60}
61
62#[utoipa::path(
63 post,
64 path = "/repos/{space_name}/{repo_name}/pipelines/{run_id}/cancel",
65 tag = "pipelines",
66 description = "请求取消排队中或运行中的流水线。",
67 params(RepoPath, ("run_id" = uuid::Uuid, Path)),
68 responses((status = 200, description = "cancel requested", body = AppResponseData<RunDetailDto>))
69)]
70pub async fn cancel_run(
71 State(service): State<CiService>,
72 session: Session,
73 Path((space_name, repo_name, run_id)): Path<(String, String, uuid::Uuid)>,
74) -> Result<AppResponseData<RunDetailDto>, ApiError> {
75 let (repo, _space) = service.resolve_repo(&space_name, &repo_name).await?;
76 service.require_write(&session, &repo).await?;
77 let detail = service.run_detail(run_id).await?;
78 if detail.run.repo_id != repo.id {
79 return Err(ApiError::not_found());
80 }
81 service.mark_run_cancel_requested(run_id).await?;
82 service.apply_cancellations().await?;
83 let detail = service.run_detail(run_id).await?;
84 Ok(AppResponseData::ok(detail))
85}
86
87#[utoipa::path(
88 post,
89 path = "/repos/{space_name}/{repo_name}/pipelines/{run_id}/rerun",
90 tag = "pipelines",
91 description = "以相同的提交与工作流定义重新创建一次运行。",
92 params(RepoPath, ("run_id" = uuid::Uuid, Path)),
93 responses((status = 200, description = "rerun created", body = AppResponseData<RunDetailDto>))
94)]
95pub async fn rerun(
96 State(service): State<CiService>,
97 session: Session,
98 Path((space_name, repo_name, run_id)): Path<(String, String, uuid::Uuid)>,
99) -> Result<AppResponseData<RunDetailDto>, ApiError> {
100 let (repo, _space) = service.resolve_repo(&space_name, &repo_name).await?;
101 let caller = service.require_write(&session, &repo).await?;
102 let existing = service.run_detail(run_id).await?;
103 if existing.run.repo_id != repo.id {
104 return Err(ApiError::not_found());
105 }
106 let new_id = service.rerun(run_id, caller).await?;
107 let detail = service.run_detail(new_id).await?;
108 Ok(AppResponseData::ok(detail))
109}
110
111#[utoipa::path(
112 post,
113 path = "/repos/{space_name}/{repo_name}/pipelines/trigger",
114 tag = "pipelines",
115 description = "手动触发一次 `workflow_dispatch` 工作流。",
116 params(RepoPath),
117 request_body = TriggerRunCmd,
118 responses((status = 200, description = "run triggered", body = AppResponseData<RunDetailDto>))
119)]
120pub async fn trigger(
121 State(service): State<CiService>,
122 session: Session,
123 Path(path): Path<RepoPath>,
124 Json(cmd): Json<TriggerRunCmd>,
125) -> Result<AppResponseData<RunDetailDto>, ApiError> {
126 let (repo, space) = service
127 .resolve_repo(&path.space_name, &path.repo_name)
128 .await?;
129 let caller = service.require_write(&session, &repo).await?;
130 let run_id = service.trigger_run(&space, &repo, caller, &cmd).await?;
131 let detail = service.run_detail(run_id).await?;
132 Ok(AppResponseData::ok(detail))
133}
134
135#[utoipa::path(
136 get,
137 path = "/repos/{space_name}/{repo_name}/pipelines/workflows",
138 tag = "pipelines",
139 description = "读取指定修订版本下 `.github/workflows/` 中的工作流清单与解析状态。",
140 params(RepoPath, WorkflowListQuery),
141 responses((status = 200, description = "workflow list", body = AppResponseData<WorkflowSummaryList>))
142)]
143pub async fn list_workflows(
144 State(service): State<CiService>,
145 session: Session,
146 Path(path): Path<RepoPath>,
147 Query(query): Query<WorkflowListQuery>,
148) -> Result<AppResponseData<WorkflowSummaryList>, ApiError> {
149 let (repo, space) = service
150 .resolve_repo(&path.space_name, &path.repo_name)
151 .await?;
152 service.require_read(&session, &repo).await?;
153 let revision = query
154 .revision
155 .unwrap_or_else(|| repo.default_branch.clone());
156 let git = git::service::GitService::new(service.state.clone());
157 let sha = git
158 .rev_parse(space.lower_name.clone(), repo.name.clone(), revision)
159 .await
160 .map_err(|_| ApiError::not_found())?;
161 let summaries = service.list_workflows(&space, &repo.name, &sha).await?;
162 Ok(AppResponseData::ok(WorkflowSummaryList {
163 revision: sha,
164 workflows: summaries,
165 }))
166}
167
168#[derive(Debug, Clone, serde::Deserialize, IntoParams)]
169pub struct WorkflowListQuery {
170 pub revision: Option<String>,
171}
Deleted lib/ci/src/handlers/space.rs
@@ -1,184 +0,0 @@
1use std::collections::HashMap;
2
3use axum::Json;
4use axum::extract::{Path, State};
5use export::rest::AppResponseData;
6use jwt::Session;
7use utoipa::ToSchema;
8use uuid::Uuid;
9
10use crate::environments::{DeploymentDto, EnvironmentDto, SecretDto};
11use crate::errors::ApiError;
12use crate::service::CiService;
13use crate::triggers::RunDto;
14
15#[derive(Debug, Clone, serde::Deserialize, ToSchema)]
16pub struct UpsertSecretCmd {
17 pub value: String,
18}
19
20#[derive(Debug, Clone, serde::Serialize, ToSchema)]
21pub struct SecretListDto {
22 pub secrets: Vec<SecretDto>,
23}
24
25#[derive(Debug, Clone, serde::Serialize, ToSchema)]
26pub struct SecretMutationDto {
27 pub secret: SecretDto,
28}
29
30#[derive(Debug, Clone, serde::Serialize, ToSchema)]
31pub struct EnvironmentListDto {
32 pub environments: Vec<EnvironmentDto>,
33}
34
35#[derive(Debug, Clone, serde::Serialize, ToSchema)]
36pub struct DeploymentListDto {
37 pub deployments: Vec<DeploymentDto>,
38 pub runs: HashMap<Uuid, RunDto>,
39}
40
41#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
42pub struct SpacePath {
43 pub space_name: String,
44}
45
46#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
47pub struct EnvironmentPath {
48 pub space_name: String,
49 pub name: String,
50}
51
52#[utoipa::path(
53 get,
54 path = "/repos/{space_name}/{repo_name}/secrets",
55 tag = "pipelines",
56 description = "列出仓库级 secrets(仅名称与时间,不回显值)。",
57 params(crate::handlers::runs::RepoPath),
58 responses((status = 200, description = "repo secrets", body = AppResponseData<SecretListDto>))
59)]
60pub async fn list_repo_secrets(
61 State(service): State<CiService>,
62 session: Session,
63 Path((space_name, repo_name)): Path<(String, String)>,
64) -> Result<AppResponseData<SecretListDto>, ApiError> {
65 let (repo, _space) = service.resolve_repo(&space_name, &repo_name).await?;
66 service.require_write(&session, &repo).await?;
67 let secrets = service.list_repo_secrets(repo.id).await?;
68 Ok(AppResponseData::ok(SecretListDto { secrets }))
69}
70
71#[utoipa::path(
72 put,
73 path = "/repos/{space_name}/{repo_name}/secrets/{name}",
74 tag = "pipelines",
75 description = "创建或更新仓库级 secret。",
76 params(crate::handlers::runs::RepoPath, ("name" = String, Path)),
77 request_body = UpsertSecretCmd,
78 responses((status = 200, description = "secret upserted", body = AppResponseData<SecretMutationDto>))
79)]
80pub async fn upsert_repo_secret(
81 State(service): State<CiService>,
82 session: Session,
83 Path((space_name, repo_name, name)): Path<(String, String, String)>,
84 Json(cmd): Json<UpsertSecretCmd>,
85) -> Result<AppResponseData<SecretMutationDto>, ApiError> {
86 let (repo, _space) = service.resolve_repo(&space_name, &repo_name).await?;
87 let caller = service.require_write(&session, &repo).await?;
88 let secret = service
89 .upsert_repo_secret(repo.id, &name, &cmd.value, Some(caller))
90 .await?;
91 Ok(AppResponseData::ok(SecretMutationDto { secret }))
92}
93
94#[utoipa::path(
95 delete,
96 path = "/repos/{space_name}/{repo_name}/secrets/{name}",
97 tag = "pipelines",
98 description = "删除仓库级 secret。",
99 params(crate::handlers::runs::RepoPath, ("name" = String, Path)),
100 responses((status = 200, description = "secret deleted", body = AppResponseData<SecretListDto>))
101)]
102pub async fn delete_repo_secret(
103 State(service): State<CiService>,
104 session: Session,
105 Path((space_name, repo_name, name)): Path<(String, String, String)>,
106) -> Result<AppResponseData<SecretListDto>, ApiError> {
107 let (repo, _space) = service.resolve_repo(&space_name, &repo_name).await?;
108 service.require_write(&session, &repo).await?;
109 service.delete_repo_secret(repo.id, &name).await?;
110 let secrets = service.list_repo_secrets(repo.id).await?;
111 Ok(AppResponseData::ok(SecretListDto { secrets }))
112}
113
114#[utoipa::path(
115 get,
116 path = "/spaces/{space_name}/secrets",
117 tag = "pipelines",
118 description = "列出空间级 secrets(仅名称与时间)。",
119 params(SpacePath),
120 responses((status = 200, description = "space secrets", body = AppResponseData<SecretListDto>))
121)]
122pub async fn list_space_secrets(
123 State(service): State<CiService>,
124 session: Session,
125 Path(path): Path<SpacePath>,
126) -> Result<AppResponseData<SecretListDto>, ApiError> {
127 let space = service.resolve_space(&path.space_name).await?;
128 service.require_space_read(&session, &space).await?;
129 let secrets = service.list_space_secrets(space.id).await?;
130 Ok(AppResponseData::ok(SecretListDto { secrets }))
131}
132
133#[utoipa::path(
134 get,
135 path = "/spaces/{space_name}/environments",
136 tag = "pipelines",
137 description = "列出空间下的部署环境。",
138 params(SpacePath),
139 responses((status = 200, description = "environments", body = AppResponseData<EnvironmentListDto>))
140)]
141pub async fn list_environments(
142 State(service): State<CiService>,
143 session: Session,
144 Path(path): Path<SpacePath>,
145) -> Result<AppResponseData<EnvironmentListDto>, ApiError> {
146 let space = service.resolve_space(&path.space_name).await?;
147 service.require_space_read(&session, &space).await?;
148 let environments = service.list_environments(&space).await?;
149 Ok(AppResponseData::ok(EnvironmentListDto { environments }))
150}
151
152#[utoipa::path(
153 get,
154 path = "/spaces/{space_name}/environments/{name}/deployments",
155 tag = "pipelines",
156 description = "列出某个环境的部署历史。",
157 params(EnvironmentPath),
158 responses((status = 200, description = "deployments", body = AppResponseData<DeploymentListDto>))
159)]
160pub async fn list_deployments(
161 State(service): State<CiService>,
162 session: Session,
163 Path(path): Path<EnvironmentPath>,
164) -> Result<AppResponseData<DeploymentListDto>, ApiError> {
165 let space = service.resolve_space(&path.space_name).await?;
166 service.require_space_read(&session, &space).await?;
167 let environment = service.find_environment(space.id, &path.name).await?;
168 let deployments = service.list_deployments(environment.id, 50).await?;
169 let mut runs = HashMap::new();
170 for deployment in &deployments {
171 if !runs.contains_key(&deployment.run_id)
172 && let Ok(detail) = service.run_detail(deployment.run_id).await
173 {
174 runs.insert(deployment.run_id, detail.run);
175 }
176 }
177 Ok(AppResponseData::ok(DeploymentListDto { deployments, runs }))
178}
179
180#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
181pub struct WorkflowSummaryList {
182 pub revision: String,
183 pub workflows: Vec<ci_protocol::dsl::WorkflowSummary>,
184}
Deleted lib/ci/src/share/access.rs
@@ -1,103 +0,0 @@
1use git::share::access::authorize_repo_access;
2use git::share::errors::GitError;
3use models::repo::repos;
4use models::space::spaces;
5use jwt::Session;
6use jwt::SessionUser;
7use uuid::Uuid;
8
9use crate::errors::CiApiError;
10use crate::service::CiService;
11
12type AccessResult<T> = Result<T, CiApiError>;
13
14fn map_git_error(error: GitError) -> CiApiError {
15 match error {
16 GitError::NotFound => CiApiError::NotFound,
17 GitError::Unauthorized => CiApiError::Unauthorized,
18 GitError::Forbidden => CiApiError::Forbidden,
19 other => CiApiError::Internal(other.to_string()),
20 }
21}
22
23impl CiService {
24 pub async fn require_read(
25 &self,
26 session: &Session,
27 repo: &repos::Model,
28 ) -> AccessResult<Option<i64>> {
29 match session.uid() {
30 Some(uid) => {
31 let ctx = self
32 .git_context(repo)
33 .await
34 .map_err(|e| CiApiError::Internal(e.to_string()))?;
35 authorize_repo_access(
36 &git::service::GitService::new(self.state.clone()),
37 uid,
38 &ctx,
39 false,
40 false,
41 )
42 .await
43 .map_err(map_git_error)?;
44 Ok(Some(uid))
45 }
46 None if !repo.is_private => Ok(None),
47 None => Err(CiApiError::Unauthorized),
48 }
49 }
50
51 pub async fn require_write(
52 &self,
53 session: &Session,
54 repo: &repos::Model,
55 ) -> AccessResult<i64> {
56 let uid = session.uid().ok_or(CiApiError::Unauthorized)?;
57 let ctx = self
58 .git_context(repo)
59 .await
60 .map_err(|e| CiApiError::Internal(e.to_string()))?;
61 authorize_repo_access(
62 &git::service::GitService::new(self.state.clone()),
63 uid,
64 &ctx,
65 true,
66 false,
67 )
68 .await
69 .map_err(map_git_error)?;
70 Ok(uid)
71 }
72
73 pub async fn require_space_read(
74 &self,
75 session: &Session,
76 space: &spaces::Model,
77 ) -> AccessResult<Option<i64>> {
78 match session.uid() {
79 Some(uid) => {
80 if space.owner_id != uid && !is_member(self, space.id, uid).await? {
81 return Err(CiApiError::Forbidden);
82 }
83 Ok(Some(uid))
84 }
85 None if !space.is_private => Ok(None),
86 None => Err(CiApiError::Unauthorized),
87 }
88 }
89}
90
91async fn is_member(service: &CiService, space_id: Uuid, uid: i64) -> AccessResult<bool> {
92 use models::space::space_members;
93 use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
94
95 let row = space_members::Entity::find()
96 .filter(space_members::Column::SpaceId.eq(space_id))
97 .filter(space_members::Column::UserId.eq(uid))
98 .filter(space_members::Column::LeftAt.is_null())
99 .one(service.db_read())
100 .await
101 .map_err(|error| CiApiError::Internal(format!("membership lookup failed: {error}")))?;
102 Ok(row.is_some())
103}
Deleted lib/ci/src/share/conditions.rs
@@ -1,129 +0,0 @@
1use crate::errors::{CiError, CiResult};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum ConditionOp {
5 Exists,
6 Equals,
7 NotEquals,
8 Glob,
9}
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct Condition {
13 pub field: ConditionField,
14 pub op: ConditionOp,
15 pub value: String,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ConditionField {
20 Tag,
21 Branch,
22 Event,
23 Environment,
24}
25
26#[derive(Debug, Clone, Default, PartialEq, Eq)]
27pub struct EvalFacts {
28 pub event: String,
29 pub branch: Option<String>,
30 pub tag: Option<String>,
31 pub environment: Option<String>,
32}
33
34pub fn parse_condition(source: &str) -> CiResult<Vec<Condition>> {
35 let mut out = Vec::new();
36 for clause in source.split("&&") {
37 let clause = clause.trim();
38 if clause.is_empty() {
39 return Err(CiError::InvalidArgument(
40 "job `if` contains an empty clause".to_string(),
41 ));
42 }
43 out.push(parse_clause(clause)?);
44 }
45 Ok(out)
46}
47
48fn parse_clause(clause: &str) -> CiResult<Condition> {
49 if let Some((left, right)) = clause.split_once("!=") {
50 let field = parse_field(left.trim())?;
51 return Ok(Condition {
52 field,
53 op: ConditionOp::NotEquals,
54 value: right.trim().to_string(),
55 });
56 }
57 if let Some((left, right)) = clause.split_once('=') {
58 let field = parse_field(left.trim())?;
59 return Ok(Condition {
60 field,
61 op: ConditionOp::Equals,
62 value: right.trim().to_string(),
63 });
64 }
65 let field = parse_field(clause)?;
66 Ok(Condition {
67 field,
68 op: ConditionOp::Exists,
69 value: String::new(),
70 })
71}
72
73fn parse_field(raw: &str) -> CiResult<ConditionField> {
74 match raw {
75 "tag" => Ok(ConditionField::Tag),
76 "branch" => Ok(ConditionField::Branch),
77 "event" => Ok(ConditionField::Event),
78 "environment" => Ok(ConditionField::Environment),
79 other => Err(CiError::InvalidArgument(format!(
80 "unsupported job `if` field `{other}` (expected tag|branch|event|environment)"
81 ))),
82 }
83}
84
85fn matches_pattern(pattern: &str, value: &str) -> bool {
86 ci_protocol::dsl::matches_patterns(&[pattern.to_string()], value)
87}
88
89#[must_use]
90pub fn condition_matches(source: &str, facts: &EvalFacts) -> bool {
91 let Ok(conditions) = parse_condition(source) else {
92 return false;
93 };
94 conditions.iter().all(|cond| match cond.field {
95 ConditionField::Tag => match cond.op {
96 ConditionOp::Exists => facts.tag.is_some(),
97 ConditionOp::Equals => facts.tag.as_deref() == Some(cond.value.as_str()),
98 ConditionOp::NotEquals => facts.tag.as_deref() != Some(cond.value.as_str()),
99 ConditionOp::Glob => facts
100 .tag
101 .as_deref()
102 .is_some_and(|t| matches_pattern(&cond.value, t)),
103 },
104 ConditionField::Branch => match cond.op {
105 ConditionOp::Exists => facts.branch.is_some(),
106 ConditionOp::Equals => facts.branch.as_deref() == Some(cond.value.as_str()),
107 ConditionOp::NotEquals => facts.branch.as_deref() != Some(cond.value.as_str()),
108 ConditionOp::Glob => facts
109 .branch
110 .as_deref()
111 .is_some_and(|b| matches_pattern(&cond.value, b)),
112 },
113 ConditionField::Event => match cond.op {
114 ConditionOp::Exists => !facts.event.is_empty(),
115 ConditionOp::Equals => facts.event == cond.value,
116 ConditionOp::NotEquals => facts.event != cond.value,
117 ConditionOp::Glob => matches_pattern(&cond.value, &facts.event),
118 },
119 ConditionField::Environment => match cond.op {
120 ConditionOp::Exists => facts.environment.is_some(),
121 ConditionOp::Equals => facts.environment.as_deref() == Some(cond.value.as_str()),
122 ConditionOp::NotEquals => facts.environment.as_deref() != Some(cond.value.as_str()),
123 ConditionOp::Glob => facts
124 .environment
125 .as_deref()
126 .is_some_and(|e| matches_pattern(&cond.value, e)),
127 },
128 })
129}
Deleted lib/ci/src/share/mod.rs
@@ -1,3 +0,0 @@
1pub mod access;
2pub mod conditions;
3pub mod states;
Deleted lib/ci/src/share/states.rs
@@ -1,12 +0,0 @@
1#[must_use]
2pub fn run_state_str(state: models::ci::workflow_run::WorkflowRunState) -> &'static str {
3 use models::ci::workflow_run::WorkflowRunState as S;
4 match state {
5 S::Queued => "queued",
6 S::Running => "running",
7 S::Success => "success",
8 S::Failure => "failure",
9 S::CancelRequested => "cancel_requested",
10 S::Cancelled => "cancelled",
11 }
12}
Deleted lib/ci/src/actions.rs
@@ -1,216 +0,0 @@
1use ci_protocol::actions::{ActionsWorkflow, EngineEvent};
2use serde_json::{Value, json};
3use tokio::io::AsyncWriteExt;
4use tokio::process::Command;
5
6use crate::errors::{CiError, CiResult};
7use crate::service::CiService;
8
9pub async fn identity<C: sea_orm::ConnectionTrait>(
10 db: &C,
11 entity_id: uuid::Uuid,
12 kind: &str,
13) -> CiResult<i64> {
14 use sea_orm::{ActiveModelTrait, Set};
15 let row = models::ci::actions_identity::ActiveModel {
16 entity_id: Set(entity_id),
17 kind: Set(kind.to_owned()),
18 ..Default::default()
19 }
20 .insert(db)
21 .await?;
22 Ok(row.id)
23}
24
25impl CiService {
26 pub async fn report_actions_event(
27 &self,
28 job_id: uuid::Uuid,
29 runner_id: uuid::Uuid,
30 event: &EngineEvent,
31 ) -> CiResult<()> {
32 use models::ci::workflow_step::WorkflowStepState as State;
33 use models::ci::{workflow_job, workflow_step};
34 use sea_orm::{
35 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
36 };
37 let job = workflow_job::Entity::find_by_id(job_id)
38 .one(self.db_write())
39 .await?
40 .ok_or(CiError::NotFound)?;
41 if job.runner_id != Some(runner_id) || job.state.is_terminal() {
42 return Err(CiError::Forbidden);
43 }
44 if event.lease_token != job.lease_token {
45 return Err(CiError::Conflict("job lease was superseded".into()));
46 }
47 let key = event.data["step_id"].as_str().unwrap_or("--job");
48 let stage = event.data["stage"].as_str().unwrap_or("main");
49 let existing = workflow_step::Entity::find()
50 .filter(workflow_step::Column::JobId.eq(job_id))
51 .filter(workflow_step::Column::StepKey.eq(key))
52 .filter(workflow_step::Column::Stage.eq(stage))
53 .one(self.db_write())
54 .await?;
55 let is_new = existing.is_none();
56 let now = time::OffsetDateTime::now_utc();
57 let mut active = match existing {
58 Some(row) => row.into(),
59 None => workflow_step::ActiveModel {
60 id: Set(uuid::Uuid::now_v7()),
61 job_id: Set(job_id),
62 step_index: Set(workflow_step::Entity::find()
63 .filter(workflow_step::Column::JobId.eq(job_id))
64 .count(self.db_write())
65 .await? as i32),
66 step_key: Set(Some(key.into())),
67 stage: Set(Some(stage.into())),
68 name: Set(event.data["name"].as_str().unwrap_or(key).to_owned()),
69 script: Set(String::new()),
70 state: Set(State::Pending),
71 created_at: Set(now),
72 updated_at: Set(now),
73 ..Default::default()
74 },
75 };
76 match event.event.as_str() {
77 "step_started" => {
78 active.state = Set(State::Running);
79 active.started_at = Set(Some(now));
80 }
81 "step_finished" => {
82 let conclusion = event.data["conclusion"].as_str().unwrap_or("failure");
83 active.state = Set(match conclusion {
84 "success" => State::Success,
85 "skipped" => State::Skipped,
86 "cancelled" => State::Cancelled,
87 _ => State::Failure,
88 });
89 active.outcome = Set(event.data["outcome"].as_str().map(str::to_owned));
90 active.conclusion = Set(Some(conclusion.into()));
91 active.outputs = Set(Some(
92 event.data.get("outputs").cloned().unwrap_or(json!({})),
93 ));
94 active.finished_at = Set(Some(now));
95 active.exit_code = Set((conclusion != "skipped")
96 .then_some(if conclusion == "success" { 0 } else { 1 }));
97 }
98 "summary" => {
99 let mut summary = active
100 .summary
101 .clone()
102 .take()
103 .unwrap_or(None)
104 .unwrap_or_default();
105 summary.push_str(event.data["text"].as_str().unwrap_or_default());
106 if summary.len() > 1024 * 1024 {
107 return Err(CiError::InvalidArgument(
108 "step summary exceeds 1 MiB".into(),
109 ));
110 }
111 active.summary = Set(Some(summary));
112 }
113 "annotation" => {
114 let mut annotations: Vec<Value> = active
115 .annotations
116 .clone()
117 .take()
118 .unwrap_or(None)
119 .and_then(|v| serde_json::from_value(v).ok())
120 .unwrap_or_default();
121 if annotations.len() < 1000 {
122 annotations.push(event.data.clone());
123 }
124 active.annotations = Set(Some(json!(annotations)));
125 }
126 _ => {
127 return Err(CiError::InvalidArgument(format!(
128 "unknown Actions event `{}`",
129 event.event
130 )));
131 }
132 }
133 if let Some(name) = event.data["name"].as_str() {
134 active.name = Set(name.into());
135 }
136 active.updated_at = Set(now);
137 if is_new {
138 active.insert(self.db_write()).await?;
139 } else {
140 active.update(self.db_write()).await?;
141 }
142 Ok(())
143 }
144
145 /// One `gitdata-actions-engine` process per call: one JSON line in, one out. The
146 /// engine is built by the separate `git-data-actions` repository; its ops are
147 /// `parse`, `match`, `trigger`, `cron`, `evaluate`, `expand`, `execute`,
148 /// `oidc_sign`, `oidc_jwks`. Any request/response field change has to land in both
149 /// repositories together.
150 pub async fn actions_engine(&self, request: Value) -> CiResult<Value> {
151 let config = &self.config().ci;
152 let mut child = Command::new(&config.actions_engine_binary)
153 .stdin(std::process::Stdio::piped())
154 .stdout(std::process::Stdio::piped())
155 .stderr(std::process::Stdio::piped())
156 .kill_on_drop(true)
157 .spawn()
158 .map_err(|error| {
159 CiError::InvalidArgument(format!(
160 "Actions engine `{}`: {error}",
161 config.actions_engine_binary
162 ))
163 })?;
164 let mut input = child
165 .stdin
166 .take()
167 .ok_or_else(|| CiError::Internal("engine stdin missing".into()))?;
168 input
169 .write_all(&serde_json::to_vec(&request)?)
170 .await
171 .map_err(|error| CiError::Internal(error.to_string()))?;
172 input
173 .write_all(b"\n")
174 .await
175 .map_err(|error| CiError::Internal(error.to_string()))?;
176 drop(input);
177 let output = tokio::time::timeout(
178 std::time::Duration::from_secs(config.actions_engine_timeout_secs.max(1)),
179 child.wait_with_output(),
180 )
181 .await
182 .map_err(|_| CiError::InvalidArgument("Actions engine request timed out".into()))?
183 .map_err(|error| CiError::Internal(error.to_string()))?;
184 let result = output
185 .stdout
186 .split(|byte| *byte == b'\n')
187 .filter_map(|line| serde_json::from_slice::<EngineEvent>(line).ok())
188 .find(|message| message.event == "result")
189 .ok_or_else(|| {
190 CiError::InvalidArgument(format!(
191 "Actions engine exited {}: {}",
192 output.status,
193 String::from_utf8_lossy(&output.stderr)
194 ))
195 })?;
196 if let Some(error) = result.error {
197 return Err(CiError::InvalidArgument(error));
198 }
199 Ok(result.data)
200 }
201
202 pub async fn parse_actions_workflow(
203 &self,
204 file: &str,
205 source: &str,
206 ) -> CiResult<ActionsWorkflow> {
207 let document = self
208 .actions_engine(json!({"op":"parse", "file":file, "source":source}))
209 .await?;
210 Ok(ActionsWorkflow {
211 file: file.to_owned(),
212 source: source.to_owned(),
213 document,
214 })
215 }
216}
Deleted lib/ci/src/actions_environments.rs
@@ -1,350 +0,0 @@
1use models::ci::{ci_environment, environment_review, workflow_job, workflow_run};
2use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
3use serde::{Deserialize, Serialize};
4use serde_json::json;
5use time::OffsetDateTime;
6use uuid::Uuid;
7
8use crate::{
9 CiService,
10 errors::{CiError, CiResult},
11};
12
13fn default_prevent_self_review() -> bool {
14 true
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
18#[serde(default)]
19pub struct EnvironmentRules {
20 pub reviewers: Vec<i64>,
21 pub wait_minutes: u32,
22 #[serde(default = "default_prevent_self_review")]
23 pub prevent_self_review: bool,
24 pub branches: Vec<String>,
25 pub tags: Vec<String>,
26}
27
28impl Default for EnvironmentRules {
29 fn default() -> Self {
30 Self {
31 reviewers: Vec::new(),
32 wait_minutes: 0,
33 prevent_self_review: true,
34 branches: Vec::new(),
35 tags: Vec::new(),
36 }
37 }
38}
39
40#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
41pub struct RepoEnvironmentDto {
42 pub id: Uuid,
43 pub name: String,
44 pub rules: EnvironmentRules,
45}
46
47#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
48pub struct ApprovalDto {
49 pub id: Uuid,
50 pub job_id: Uuid,
51 pub environment: String,
52 pub state: String,
53 pub eligible_at: OffsetDateTime,
54 pub reviewer_id: Option<i64>,
55 pub reviewers: Vec<i64>,
56 pub prevent_self_review: bool,
57}
58
59impl CiService {
60 pub async fn find_actions_environment(
61 &self,
62 space_id: Uuid,
63 repo_id: Uuid,
64 name: &str,
65 ) -> CiResult<Option<ci_environment::Model>> {
66 let rows = ci_environment::Entity::find()
67 .filter(ci_environment::Column::SpaceId.eq(space_id))
68 .all(self.db_write())
69 .await?;
70 let lowered = name.to_lowercase();
71 Ok(rows
72 .iter()
73 .find(|row| row.repo_id == Some(repo_id) && row.name.to_lowercase() == lowered)
74 .cloned()
75 .or_else(|| {
76 rows.into_iter()
77 .find(|row| row.repo_id.is_none() && row.name.to_lowercase() == lowered)
78 }))
79 }
80
81 pub async fn upsert_actions_environment(
82 &self,
83 space_id: Uuid,
84 repo_id: Uuid,
85 name: &str,
86 rules: &EnvironmentRules,
87 ) -> CiResult<RepoEnvironmentDto> {
88 let name = name.trim().to_lowercase();
89 if name.is_empty()
90 || name.len() > 255
91 || rules.wait_minutes > 43200
92 || rules.reviewers.len() > 6
93 {
94 return Err(CiError::InvalidArgument(
95 "invalid environment name, wait time or reviewers".into(),
96 ));
97 }
98 for reviewer in &rules.reviewers {
99 if models::users::user::Entity::find_by_id(*reviewer)
100 .one(self.db_read())
101 .await?
102 .is_none()
103 {
104 return Err(CiError::InvalidArgument(format!(
105 "reviewer `{reviewer}` was not found"
106 )));
107 }
108 }
109 let existing = ci_environment::Entity::find()
110 .filter(ci_environment::Column::RepoId.eq(repo_id))
111 .filter(ci_environment::Column::Name.eq(&name))
112 .one(self.db_write())
113 .await?;
114 let now = OffsetDateTime::now_utc();
115 let row = if let Some(row) = existing {
116 let mut active: ci_environment::ActiveModel = row.into();
117 active.rules = Set(Some(json!(rules)));
118 active.updated_at = Set(now);
119 active.update(self.db_write()).await?
120 } else {
121 ci_environment::ActiveModel {
122 id: Set(Uuid::now_v7()),
123 space_id: Set(space_id),
124 repo_id: Set(Some(repo_id)),
125 name: Set(name),
126 description: Set(None),
127 required_labels: Set(json!([])),
128 rules: Set(Some(json!(rules))),
129 created_at: Set(now),
130 updated_at: Set(now),
131 }
132 .insert(self.db_write())
133 .await?
134 };
135 Ok(RepoEnvironmentDto {
136 id: row.id,
137 name: row.name,
138 rules: rules.clone(),
139 })
140 }
141
142 pub async fn repo_actions_environments(
143 &self,
144 repo_id: Uuid,
145 ) -> CiResult<Vec<RepoEnvironmentDto>> {
146 let rows = ci_environment::Entity::find()
147 .filter(ci_environment::Column::RepoId.eq(repo_id))
148 .all(self.db_read())
149 .await?;
150 Ok(rows
151 .into_iter()
152 .map(|row| RepoEnvironmentDto {
153 id: row.id,
154 name: row.name,
155 rules: row
156 .rules
157 .and_then(|value| serde_json::from_value(value).ok())
158 .unwrap_or_default(),
159 })
160 .collect())
161 }
162
163 pub async fn actions_environment_available(&self, job: &workflow_job::Model) -> CiResult<bool> {
164 let Some(name) = job.environment.as_deref() else {
165 return Ok(true);
166 };
167 let Some(environment) = self
168 .find_actions_environment(job.space_id, job.repo_id, name)
169 .await?
170 else {
171 return Ok(true);
172 };
173 let rules: EnvironmentRules = environment
174 .rules
175 .clone()
176 .and_then(|value| serde_json::from_value(value).ok())
177 .unwrap_or_default();
178 let run = workflow_run::Entity::find_by_id(job.run_id)
179 .one(self.db_write())
180 .await?
181 .ok_or(CiError::NotFound)?;
182 let patterns = if run.tag.is_some() {
183 &rules.tags
184 } else {
185 &rules.branches
186 };
187 if !patterns.is_empty() {
188 let ref_name = run
189 .tag
190 .as_deref()
191 .or(run.branch.as_deref())
192 .unwrap_or_default();
193 let matched = self
194 .actions_engine(json!({"op":"match","patterns":patterns,"paths":[ref_name]}))
195 .await?
196 .as_bool()
197 .unwrap_or(false);
198 if !matched {
199 let mut active: workflow_job::ActiveModel = job.clone().into();
200 active.state = Set(workflow_job::WorkflowJobState::Skipped);
201 active.outcome = Set(Some("skipped".into()));
202 active.error = Set(Some(format!(
203 "ref `{ref_name}` does not match environment deployment rules"
204 )));
205 active.finished_at = Set(Some(OffsetDateTime::now_utc()));
206 active.update(self.db_write()).await?;
207 return Ok(false);
208 }
209 }
210 if rules.reviewers.is_empty() && rules.wait_minutes == 0 {
211 return Ok(true);
212 }
213 let review = environment_review::Entity::find()
214 .filter(environment_review::Column::JobId.eq(job.id))
215 .one(self.db_write())
216 .await?;
217 let review = if let Some(review) = review {
218 review
219 } else {
220 let now = OffsetDateTime::now_utc();
221 environment_review::ActiveModel {
222 id: Set(Uuid::now_v7()),
223 job_id: Set(job.id),
224 repo_id: Set(job.repo_id),
225 environment_id: Set(environment.id),
226 state: Set(if rules.reviewers.is_empty() {
227 "approved"
228 } else {
229 "pending"
230 }
231 .into()),
232 reviewer_id: Set(None),
233 comment: Set(None),
234 requested_at: Set(now),
235 eligible_at: Set(now + time::Duration::minutes(i64::from(rules.wait_minutes))),
236 updated_at: Set(now),
237 }
238 .insert(self.db_write())
239 .await?
240 };
241 if review.state == "rejected" {
242 let mut active: workflow_job::ActiveModel = job.clone().into();
243 active.state = Set(workflow_job::WorkflowJobState::Failure);
244 active.outcome = Set(Some("failure".into()));
245 active.error = Set(Some("environment deployment was rejected".into()));
246 active.finished_at = Set(Some(OffsetDateTime::now_utc()));
247 active.update(self.db_write()).await?;
248 return Ok(false);
249 }
250 Ok(review.state == "approved" && review.eligible_at <= OffsetDateTime::now_utc())
251 }
252
253 pub async fn actions_approvals(
254 &self,
255 repo_id: Uuid,
256 run_id: Uuid,
257 ) -> CiResult<Vec<ApprovalDto>> {
258 let jobs = workflow_job::Entity::find()
259 .filter(workflow_job::Column::RepoId.eq(repo_id))
260 .filter(workflow_job::Column::RunId.eq(run_id))
261 .all(self.db_read())
262 .await?;
263 let mut result = Vec::new();
264 for job in jobs {
265 if let Some(review) = environment_review::Entity::find()
266 .filter(environment_review::Column::JobId.eq(job.id))
267 .one(self.db_read())
268 .await?
269 {
270 let environment = ci_environment::Entity::find_by_id(review.environment_id)
271 .one(self.db_read())
272 .await?
273 .ok_or(CiError::NotFound)?;
274 let rules: EnvironmentRules = environment
275 .rules
276 .and_then(|value| serde_json::from_value(value).ok())
277 .unwrap_or_default();
278 result.push(ApprovalDto {
279 id: review.id,
280 job_id: job.id,
281 environment: environment.name,
282 state: review.state,
283 eligible_at: review.eligible_at,
284 reviewer_id: review.reviewer_id,
285 reviewers: rules.reviewers,
286 prevent_self_review: rules.prevent_self_review,
287 });
288 }
289 }
290 Ok(result)
291 }
292
293 pub async fn review_actions_environment(
294 &self,
295 repo_id: Uuid,
296 run_id: Uuid,
297 review_id: Uuid,
298 caller: i64,
299 approved: bool,
300 comment: Option<String>,
301 ) -> CiResult<()> {
302 use sea_orm::{QuerySelect, TransactionTrait};
303 let txn = self.db_write().begin().await?;
304 let review = environment_review::Entity::find_by_id(review_id)
305 .lock_exclusive()
306 .one(&txn)
307 .await?
308 .ok_or(CiError::NotFound)?;
309 let job = workflow_job::Entity::find_by_id(review.job_id)
310 .one(&txn)
311 .await?
312 .ok_or(CiError::NotFound)?;
313 if review.repo_id != repo_id || job.run_id != run_id {
314 return Err(CiError::NotFound);
315 }
316 if review.state != "pending" || job.state.is_terminal() {
317 return Err(CiError::Conflict(
318 "environment review is already finished".into(),
319 ));
320 }
321 let env = ci_environment::Entity::find_by_id(review.environment_id)
322 .one(&txn)
323 .await?
324 .ok_or(CiError::NotFound)?;
325 let rules: EnvironmentRules = env
326 .rules
327 .and_then(|value| serde_json::from_value(value).ok())
328 .unwrap_or_default();
329 if !rules.reviewers.contains(&caller) {
330 return Err(CiError::Forbidden);
331 }
332 let run = workflow_run::Entity::find_by_id(run_id)
333 .one(&txn)
334 .await?
335 .ok_or(CiError::NotFound)?;
336 if rules.prevent_self_review && run.trigger_user_id == Some(caller) {
337 return Err(CiError::InvalidArgument(
338 "self review is disabled for this environment".into(),
339 ));
340 }
341 let mut active: environment_review::ActiveModel = review.into();
342 active.state = Set(if approved { "approved" } else { "rejected" }.into());
343 active.reviewer_id = Set(Some(caller));
344 active.comment = Set(comment);
345 active.updated_at = Set(OffsetDateTime::now_utc());
346 active.update(&txn).await?;
347 txn.commit().await?;
348 Ok(())
349 }
350}
Deleted lib/ci/src/actions_events.rs
@@ -1,360 +0,0 @@
1use ci_protocol::events::{CiEvent, TriggerEvent};
2use models::ci::workflow_run::WorkflowEventKind;
3use models::ci::{actions_schedule, workflow_run};
4use sea_orm::{
5 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set, TransactionTrait,
6};
7use serde_json::{Value, json};
8use time::OffsetDateTime;
9use uuid::Uuid;
10
11use crate::{
12 CiService, LoadedWorkflow,
13 errors::{CiError, CiResult},
14 runs::RunSeed,
15};
16
17#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)]
18pub struct DispatchCmd {
19 pub event_type: String,
20 #[serde(default)]
21 pub client_payload: Value,
22}
23
24impl CiService {
25 pub async fn repository_dispatch(
26 &self,
27 repo: &models::repo::repos::Model,
28 space: &models::space::spaces::Model,
29 caller: i64,
30 command: &DispatchCmd,
31 ) -> CiResult<Vec<Uuid>> {
32 self.ensure_enabled()?;
33 if command.event_type.trim().is_empty()
34 || command.event_type.len() > 100
35 || serde_json::to_vec(&command.client_payload)?.len() > 65536
36 {
37 return Err(CiError::InvalidArgument(
38 "invalid repository_dispatch event type or payload".into(),
39 ));
40 }
41 let event = TriggerEvent {
42 id: Uuid::now_v7(),
43 repo_id: repo.id,
44 space_id: space.id,
45 actor_id: Some(caller),
46 event: "repository_dispatch".into(),
47 payload: json!({"action":command.event_type,"client_payload":command.client_payload}),
48 trace: export::telemetry::traceparent(),
49 request_id: export::telemetry::request_id(),
50 };
51 self.handle_trigger_event(&event).await
52 }
53
54 pub async fn handle_trigger_event(&self, event: &TriggerEvent) -> CiResult<Vec<Uuid>> {
55 let repo = self.repo_by_id(event.repo_id).await?;
56 let space = self.space_of_repo(&repo).await?;
57 if space.id != event.space_id {
58 return Err(CiError::InvalidArgument(
59 "event space does not match repository".into(),
60 ));
61 }
62 let git = git::service::GitService::new(self.state.clone());
63 let sha = git
64 .rev_parse(
65 space.lower_name.clone(),
66 repo.name.clone(),
67 repo.default_branch.clone(),
68 )
69 .await?;
70 let workflows = self.load_valid_workflows(&space, &repo.name, &sha).await?;
71 let kind = match event.event.as_str() {
72 "repository_dispatch" => WorkflowEventKind::RepositoryDispatch,
73 "workflow_run" => WorkflowEventKind::WorkflowRun,
74 _ => return Err(CiError::InvalidArgument("unsupported event source".into())),
75 };
76 let mut payload = event.payload.clone();
77 payload["event_id"] = json!(event.id);
78 let seed = RunSeed {
79 event: kind,
80 event_payload: payload,
81 commit_sha: sha,
82 git_ref: format!("refs/heads/{}", repo.default_branch),
83 branch: event.payload["workflow_run"]["head_branch"]
84 .as_str()
85 .map(str::to_owned)
86 .or_else(|| Some(repo.default_branch.clone())),
87 tag: None,
88 base_sha: None,
89 base_ref: None,
90 pr_number: None,
91 trigger_user_id: event.actor_id,
92 };
93 let mut created = Vec::new();
94 for workflow in workflows {
95 if self
96 .actions_event_matches(&workflow, &seed, Vec::new())
97 .await?
98 {
99 created.push(self.create_run(&repo, &space, &seed, &workflow).await?)
100 }
101 }
102 Ok(created)
103 }
104
105 pub async fn actions_event_matches(
106 &self,
107 workflow: &LoadedWorkflow,
108 seed: &RunSeed,
109 paths: Vec<String>,
110 ) -> CiResult<bool> {
111 let Some(actions) = &workflow.actions else {
112 return Ok(false);
113 };
114 let action = seed.event_payload["action"].as_str().unwrap_or_default();
115 let result=self.actions_engine(json!({"op":"trigger","source":actions.source,"file":actions.file,"trigger_event":seed.event.as_str(),"branch":if seed.event==WorkflowEventKind::PullRequest{seed.base_ref.as_deref()}else{seed.branch.as_deref()},"tag":seed.tag,"action":action,"paths":paths,"expression":seed.event_payload["workflow_run"]["name"].as_str().unwrap_or_default()})).await?;
116 Ok(result.as_bool().unwrap_or(false))
117 }
118
119 pub async fn actions_changed_paths(
120 &self,
121 repo_id: Uuid,
122 seed: &RunSeed,
123 ) -> CiResult<Vec<String>> {
124 let repo = models::repo::repos::Entity::find_by_id(repo_id)
125 .one(self.db_read())
126 .await?
127 .ok_or(CiError::NotFound)?;
128 let space = self.space_of_repo(&repo).await?;
129 let service = git::service::GitService::new(self.state.clone());
130 let before = seed
131 .base_sha
132 .as_deref()
133 .or_else(|| seed.event_payload["old_oid"].as_str())
134 .filter(|oid| !oid.is_empty() && !oid.chars().all(|ch| ch == '0'));
135 let paths = match before {
136 Some(before) => {
137 service
138 .diff_files_changed_between(
139 space.lower_name,
140 repo.name,
141 before.to_string(),
142 seed.commit_sha.clone(),
143 )
144 .await?
145 }
146 None => service
147 .tree_walk(
148 space.lower_name,
149 repo.name,
150 seed.commit_sha.clone(),
151 true,
152 false,
153 )
154 .await?
155 .into_iter()
156 .map(|entry| entry.name)
157 .collect(),
158 };
159 Ok(paths)
160 }
161
162 pub async fn sync_actions_schedules(
163 &self,
164 repo_id: Uuid,
165 workflows: &[LoadedWorkflow],
166 ) -> CiResult<()> {
167 let existing = actions_schedule::Entity::find()
168 .filter(actions_schedule::Column::RepoId.eq(repo_id))
169 .all(self.db_write())
170 .await?;
171 let now = OffsetDateTime::now_utc();
172 let mut wanted = std::collections::BTreeSet::new();
173 for workflow in workflows {
174 let Some(actions) = &workflow.actions else {
175 continue;
176 };
177 for schedule in actions.document["on"]["schedule"]
178 .as_array()
179 .into_iter()
180 .flatten()
181 {
182 let cron = schedule["cron"]
183 .as_str()
184 .ok_or_else(|| CiError::InvalidArgument("schedule cron is required".into()))?;
185 wanted.insert((workflow.file.clone(), cron.to_owned()));
186 if existing
187 .iter()
188 .any(|row| row.workflow_file == workflow.file && row.cron == cron)
189 {
190 continue;
191 }
192 let next = self
193 .actions_engine(
194 json!({"op":"cron","expression":cron,"value":now.unix_timestamp()}),
195 )
196 .await?
197 .as_i64()
198 .ok_or_else(|| CiError::InvalidArgument("invalid cron next time".into()))?;
199 actions_schedule::ActiveModel {
200 id: Set(Uuid::now_v7()),
201 repo_id: Set(repo_id),
202 workflow_file: Set(workflow.file.clone()),
203 cron: Set(cron.into()),
204 next_at: Set(OffsetDateTime::from_unix_timestamp(next)
205 .map_err(|error| CiError::InvalidArgument(error.to_string()))?),
206 updated_at: Set(now),
207 }
208 .insert(self.db_write())
209 .await?;
210 }
211 }
212 for row in existing {
213 if !wanted.contains(&(row.workflow_file, row.cron)) {
214 actions_schedule::Entity::delete_by_id(row.id)
215 .exec(self.db_write())
216 .await?;
217 }
218 }
219 Ok(())
220 }
221
222 #[tracing::instrument(name = "ci.discover_schedules", skip_all)]
223 pub async fn discover_actions_schedules(&self) -> CiResult<()> {
224 let repos = models::repo::repos::Entity::find()
225 .all(self.db_read())
226 .await?;
227 for repo in repos {
228 let result: CiResult<()> = async {
229 let space = self.space_of_repo(&repo).await?;
230 let workflows = self
231 .load_valid_workflows(&space, &repo.name, &repo.default_branch)
232 .await?;
233 self.sync_actions_schedules(repo.id, &workflows).await
234 }
235 .await;
236 if let Err(error) = result {
237 tracing::warn!(repo=%repo.id,%error,"schedule discovery failed");
238 }
239 }
240 Ok(())
241 }
242
243 #[tracing::instrument(name = "ci.run_schedules", skip_all)]
244 pub async fn run_due_actions_schedules(&self) -> CiResult<u64> {
245 let now = OffsetDateTime::now_utc();
246 let due = actions_schedule::Entity::find()
247 .filter(actions_schedule::Column::NextAt.lte(now))
248 .all(self.db_write())
249 .await?;
250 let mut count = 0;
251 for due in due {
252 let txn = self.db_write().begin().await?;
253 let Some(current) = actions_schedule::Entity::find_by_id(due.id)
254 .lock_exclusive()
255 .one(&txn)
256 .await?
257 else {
258 continue;
259 };
260 if current.next_at > now {
261 continue;
262 }
263 let repo = self.repo_by_id(current.repo_id).await?;
264 let space = self.space_of_repo(&repo).await?;
265 let git = git::service::GitService::new(self.state.clone());
266 let sha = git
267 .rev_parse(
268 space.lower_name.clone(),
269 repo.name.clone(),
270 repo.default_branch.clone(),
271 )
272 .await?;
273 let workflow = self
274 .load_workflow_by_file(&space, &repo.name, &sha, &current.workflow_file)
275 .await?;
276 let still_declared = workflow.actions.as_ref().is_some_and(|actions| {
277 actions.document["on"]["schedule"]
278 .as_array()
279 .is_some_and(|values| {
280 values
281 .iter()
282 .any(|value| value["cron"].as_str() == Some(current.cron.as_str()))
283 })
284 });
285 if !still_declared {
286 actions_schedule::Entity::delete_by_id(current.id)
287 .exec(&txn)
288 .await?;
289 txn.commit().await?;
290 continue;
291 }
292 let seed = RunSeed {
293 event: WorkflowEventKind::Schedule,
294 event_payload: json!({"schedule":current.cron,"event_id":format!("schedule:{}:{}",current.id,current.next_at.unix_timestamp())}),
295 commit_sha: sha,
296 git_ref: format!("refs/heads/{}", repo.default_branch),
297 branch: Some(repo.default_branch.clone()),
298 tag: None,
299 base_sha: None,
300 base_ref: None,
301 pr_number: None,
302 trigger_user_id: None,
303 };
304 self.create_run(&repo, &space, &seed, &workflow).await?;
305 let next = self
306 .actions_engine(
307 json!({"op":"cron","expression":current.cron,"value":now.unix_timestamp()}),
308 )
309 .await?
310 .as_i64()
311 .ok_or_else(|| CiError::InvalidArgument("invalid cron next time".into()))?;
312 let mut active: actions_schedule::ActiveModel = current.into();
313 active.next_at = Set(OffsetDateTime::from_unix_timestamp(next)
314 .map_err(|error| CiError::InvalidArgument(error.to_string()))?);
315 active.updated_at = Set(now);
316 active.update(&txn).await?;
317 txn.commit().await?;
318 count += 1;
319 }
320 Ok(count)
321 }
322
323 pub async fn emit_actions_workflow_run(
324 &self,
325 run: &workflow_run::Model,
326 action: &str,
327 ) -> CiResult<()> {
328 if run.event_payload["_workflow_run_depth"]
329 .as_u64()
330 .unwrap_or(0)
331 >= 3
332 {
333 return Ok(());
334 }
335 let repo = self.repo_by_id(run.repo_id).await?;
336 let space = self.space_of_repo(&repo).await?;
337 use sha2::Digest;
338 let hash = sha2::Sha256::digest(
339 format!("workflow_run:{}:{action}:{}", run.id, run.attempt).as_bytes(),
340 );
341 let mut identity = [0u8; 16];
342 identity.copy_from_slice(&hash[..16]);
343 let event = CiEvent::Trigger(TriggerEvent {
344 id: Uuid::from_bytes(identity),
345 repo_id: repo.id,
346 space_id: space.id,
347 actor_id: run.trigger_user_id,
348 event: "workflow_run".into(),
349 payload: json!({"action":action,"_workflow_run_depth":run.event_payload["_workflow_run_depth"].as_u64().unwrap_or(0)+1,"workflow_run":{"id":run.actions_id,"name":run.workflow_name,"head_branch":run.branch,"head_sha":run.commit_sha,"status":if action=="completed"{"completed"}else if action=="requested"{"queued"}else{"in_progress"},"conclusion":if action=="completed"{Some(crate::share::states::run_state_str(run.state))}else{None},"event":run.event.as_str()}}),
350 trace: export::telemetry::traceparent(),
351 request_id: export::telemetry::request_id(),
352 });
353 crate::events::publish_event(
354 &self.state.redis,
355 &self.config().ci.event_queue_topic,
356 &event,
357 )
358 .await
359 }
360}
Deleted lib/ci/src/actions_payload.rs
@@ -1,202 +0,0 @@
1use std::collections::BTreeMap;
2
3use ci_protocol::actions::{ActionsExecution, ActionsWorkflow, NeedResult};
4use ci_protocol::runner::{JobPayload, LeaseDto};
5use models::ci::{job_token, workflow_job, workflow_run};
6use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
7use serde_json::{Value, json};
8use time::OffsetDateTime;
9
10use crate::{
11 CiService,
12 errors::{CiError, CiResult},
13};
14
15impl CiService {
16 pub async fn build_actions_job_payload(
17 &self,
18 job: &workflow_job::Model,
19 run: &workflow_run::Model,
20 lease_token: String,
21 ) -> CiResult<JobPayload> {
22 let context = job
23 .actions_context
24 .as_ref()
25 .ok_or_else(|| CiError::Internal("Actions context missing".into()))?;
26 let workflow: ActionsWorkflow = serde_json::from_value(context["workflow"].clone())?;
27 let repo = self.repo_by_id(job.repo_id).await?;
28 let space = self.space_of_repo(&repo).await?;
29 let mut secrets = self.actions_job_secrets(job, run, context).await?;
30 let fork = run.event_payload["pull_request"]["head"]["repo"]["fork"]
31 .as_bool()
32 .unwrap_or(false);
33 if fork {
34 secrets.clear();
35 }
36 let id = uuid::Uuid::now_v7();
37 let secret = crate::secrets::random_secret(32);
38 let token = format!("ci_{id}.{secret}");
39 let permissions = &context["permissions"];
40 job_token::ActiveModel {
41 id: Set(id),
42 job_id: Set(job.id),
43 repo_id: Set(job.repo_id),
44 actor_id: Set(run.trigger_user_id.unwrap_or(space.owner_id)),
45 token_hash: Set(crate::secrets::sha256_hex(secret.as_bytes())),
46 lease_token: Set(lease_token.clone()),
47 contents_read: Set(matches!(
48 permissions["contents"].as_str(),
49 Some("read" | "write")
50 )),
51 contents_write: Set(!fork && permissions["contents"].as_str() == Some("write")),
52 expires_at: Set(OffsetDateTime::now_utc()
53 + time::Duration::seconds(self.config().ci.job_timeout_max_secs as i64 + 600)),
54 }
55 .insert(self.db_write())
56 .await?;
57 secrets.insert("GITHUB_TOKEN".into(), token.clone());
58 let needs: BTreeMap<String, NeedResult> =
59 serde_json::from_value(context["needs_results"].clone()).unwrap_or_default();
60 let timeout = context["instance"]["timeout_seconds"]
61 .as_u64()
62 .unwrap_or(self.config().ci.job_timeout_max_secs)
63 .min(self.config().ci.job_timeout_max_secs)
64 .max(1);
65 let mut github = self.actions_github_context(run).await?;
66 github["job"] = context["job"].clone();
67 github["token"] = json!(token);
68 let mut env = BTreeMap::from([
69 ("CI".into(), "true".into()),
70 ("GITDATA_CI".into(), "true".into()),
71 ("GITDATA_RUN_ID".into(), run.id.to_string()),
72 ("GITDATA_JOB_ID".into(), job.id.to_string()),
73 ]);
74 if let Some(environment) = &job.environment {
75 env.insert("GITDATA_ENVIRONMENT".into(), environment.clone());
76 }
77 let actions = ActionsExecution {
78 workflow,
79 job: context["job"].as_str().unwrap_or(&job.job_key).into(),
80 matrix: context["instance"]["matrix"].clone(),
81 github,
82 event: run.event_payload.clone(),
83 needs,
84 inputs: serde_json::from_value(context["inputs"].clone()).unwrap_or_default(),
85 vars: self.actions_variables(job.repo_id).await?,
86 token,
87 runtime_url: format!("{}/", self.actions_runtime_url()?),
88 runtime_token: self.actions_runtime_token(job, run)?,
89 };
90 Ok(JobPayload {
91 job_id: job.id,
92 run_id: run.id,
93 repo_id: repo.id,
94 space_id: space.id,
95 run_attempt: job.attempt,
96 job_name: job.name.clone(),
97 clone_url: format!(
98 "{}/{}/{}.git",
99 self.config().git.base_url.trim_end_matches('/'),
100 space.lower_name,
101 repo.name
102 ),
103 commit_sha: run.commit_sha.clone(),
104 git_ref: run.git_ref.clone(),
105 branch: run.branch.clone(),
106 tag: run.tag.clone(),
107 base_sha: run.base_sha.clone(),
108 base_ref: run.base_ref.clone(),
109 pr_number: run.pr_number,
110 event: run.event.as_str().into(),
111 workflow_name: run.workflow_name.clone(),
112 workflow_file: run.workflow_file.clone(),
113 env,
114 secrets,
115 allow_git_token: false,
116 container: None,
117 timeout_secs: timeout,
118 steps: Vec::new(),
119 artifacts: Vec::new(),
120 needs_artifacts: Vec::new(),
121 lease: LeaseDto {
122 token: lease_token,
123 expires_at: job.lease_expires_at.unwrap_or(OffsetDateTime::now_utc()),
124 heartbeat_interval_secs: self.config().ci.heartbeat_interval_secs,
125 attempt: job.attempt,
126 },
127 trace: run
128 .event_payload
129 .get("_gitdata_trace")
130 .and_then(|value| value.as_str())
131 .map(str::to_owned),
132 request_id: run
133 .event_payload
134 .get("_gitdata_request_id")
135 .and_then(|value| value.as_str())
136 .map(str::to_owned),
137 actions: Some(actions),
138 })
139 }
140
141 async fn actions_job_secrets(
142 &self,
143 job: &workflow_job::Model,
144 run: &workflow_run::Model,
145 context: &Value,
146 ) -> CiResult<BTreeMap<String, String>> {
147 let mut contexts = Vec::new();
148 let mut cursor = context;
149 while cursor
150 .get("caller_context")
151 .is_some_and(|value| value.is_object())
152 {
153 contexts.push(cursor.clone());
154 cursor = &cursor["caller_context"];
155 if contexts.len() > 10 {
156 return Err(CiError::InvalidArgument(
157 "workflow call depth exceeds 10".into(),
158 ));
159 }
160 }
161 let mut secrets: BTreeMap<String, String> = self
162 .resolved_secrets(job.repo_id, job.space_id, None)
163 .await?
164 .into_iter()
165 .collect();
166 for current in contexts.into_iter().rev() {
167 let mapping = &current["secret_mapping"];
168 if mapping.as_str() == Some("inherit") {
169 continue;
170 }
171 if mapping.is_null() {
172 secrets.clear();
173 continue;
174 }
175 let needs: BTreeMap<String, NeedResult> =
176 serde_json::from_value(current["caller_needs"].clone()).unwrap_or_default();
177 let mut request = self
178 .actions_request(run, &current["caller_context"], &needs, "evaluate")
179 .await?;
180 request["value"] = mapping.clone();
181 request["secrets"] = json!(secrets);
182 secrets = serde_json::from_value(self.actions_engine(request).await?)?;
183 }
184 if let Some(environment) = &job.environment
185 && let Some(environment) = self
186 .find_actions_environment(job.space_id, job.repo_id, environment)
187 .await?
188 {
189 let rows = models::ci::ci_secret::Entity::find()
190 .filter(models::ci::ci_secret::Column::EnvironmentId.eq(environment.id))
191 .all(self.db_write())
192 .await?;
193 for row in rows {
194 secrets.insert(
195 row.name,
196 crate::secrets::decrypt(self.encryption_key(), &row.value_ciphertext)?,
197 );
198 }
199 }
200 Ok(secrets)
201 }
202}
Deleted lib/ci/src/actions_runtime.rs
@@ -1,1042 +0,0 @@
1use axum::{
2 Json, Router,
3 body::Bytes,
4 extract::{DefaultBodyLimit, Path, Query, State},
5 http::{HeaderMap, StatusCode},
6 response::{IntoResponse, Response},
7 routing::{get, post, put},
8};
9use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
10use futures::StreamExt;
11use hmac::{Hmac, KeyInit, Mac};
12use models::ci::workflow_artifact::ArtifactState;
13use models::ci::{actions_cache, actions_identity, workflow_artifact, workflow_job, workflow_run};
14use sea_orm::{
15 ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder, Set,
16 TransactionTrait,
17};
18use serde::Deserialize;
19use serde_json::{Value, json};
20use sha2::{Digest, Sha256};
21use time::OffsetDateTime;
22use tokio::io::{AsyncSeekExt, AsyncWriteExt};
23use uuid::Uuid;
24
25use crate::{
26 CiService,
27 errors::{CiError, CiResult},
28};
29
30pub fn router() -> Router<CiService> {
31 Router::new()
32 .route(
33 "/twirp/github.actions.results.api.v1.ArtifactService/{method}",
34 post(artifact_rpc),
35 )
36 .route(
37 "/twirp/github.actions.results.api.v1.CacheService/{method}",
38 post(cache_rpc),
39 )
40 .route("/actions-runtime/uploads/{kind}/{id}", put(upload_blob))
41 .route("/actions-runtime/oidc", get(oidc_token))
42 .route("/.well-known/openid-configuration", get(oidc_configuration))
43 .route("/.well-known/jwks.json", get(oidc_jwks))
44 .route("/_apis/artifactcache/cache", get(legacy_cache_lookup))
45 .route("/_apis/artifactcache/caches", post(legacy_cache_reserve))
46 .route(
47 "/_apis/artifactcache/caches/{id}",
48 post(legacy_cache_finalize).patch(legacy_cache_upload),
49 )
50 .layer(DefaultBodyLimit::max(64 * 1024 * 1024))
51}
52
53pub struct RuntimeError(CiError);
54impl From<CiError> for RuntimeError {
55 fn from(error: CiError) -> Self {
56 Self(error)
57 }
58}
59impl From<storage::StorageError> for RuntimeError {
60 fn from(error: storage::StorageError) -> Self {
61 Self(error.into())
62 }
63}
64impl From<sea_orm::DbErr> for RuntimeError {
65 fn from(error: sea_orm::DbErr) -> Self {
66 Self(error.into())
67 }
68}
69impl IntoResponse for RuntimeError {
70 fn into_response(self) -> Response {
71 let (status, code) = match &self.0 {
72 CiError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthenticated"),
73 CiError::Forbidden => (StatusCode::FORBIDDEN, "permission_denied"),
74 CiError::NotFound => (StatusCode::NOT_FOUND, "not_found"),
75 CiError::Conflict(_) => (StatusCode::CONFLICT, "already_exists"),
76 CiError::InvalidArgument(_) => (StatusCode::BAD_REQUEST, "invalid_argument"),
77 _ => (StatusCode::INTERNAL_SERVER_ERROR, "internal"),
78 };
79 (status, Json(json!({"code":code,"msg":self.0.to_string()}))).into_response()
80 }
81}
82
83#[derive(Clone)]
84struct RuntimeContext {
85 job: workflow_job::Model,
86 run: workflow_run::Model,
87}
88
89impl CiService {
90 pub fn sign_actions_token(&self, payload: &Value) -> CiResult<String> {
91 let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#);
92 let data = URL_SAFE_NO_PAD.encode(serde_json::to_vec(payload)?);
93 let message = format!("{header}.{data}");
94 let mut mac = Hmac::<Sha256>::new_from_slice(self.encryption_key().as_bytes())
95 .map_err(|error| CiError::Internal(error.to_string()))?;
96 mac.update(message.as_bytes());
97 Ok(format!(
98 "{message}.{}",
99 URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
100 ))
101 }
102
103 fn verify_actions_token(&self, token: &str) -> CiResult<Value> {
104 let parts: Vec<_> = token.split('.').collect();
105 if parts.len() != 3 {
106 return Err(CiError::Unauthorized);
107 }
108 let mut mac = Hmac::<Sha256>::new_from_slice(self.encryption_key().as_bytes())
109 .map_err(|error| CiError::Internal(error.to_string()))?;
110 mac.update(format!("{}.{}", parts[0], parts[1]).as_bytes());
111 let signature = URL_SAFE_NO_PAD
112 .decode(parts[2])
113 .map_err(|_| CiError::Unauthorized)?;
114 mac.verify_slice(&signature)
115 .map_err(|_| CiError::Unauthorized)?;
116 let data = URL_SAFE_NO_PAD
117 .decode(parts[1])
118 .map_err(|_| CiError::Unauthorized)?;
119 let value: Value = serde_json::from_slice(&data).map_err(|_| CiError::Unauthorized)?;
120 if value["exp"].as_i64().unwrap_or(0) <= OffsetDateTime::now_utc().unix_timestamp() {
121 return Err(CiError::Unauthorized);
122 }
123 Ok(value)
124 }
125
126 async fn runtime_context(&self, headers: &HeaderMap) -> CiResult<RuntimeContext> {
127 self.ensure_enabled()?;
128 let token = headers
129 .get("authorization")
130 .and_then(|value| value.to_str().ok())
131 .and_then(|value| value.strip_prefix("Bearer "))
132 .ok_or(CiError::Unauthorized)?;
133 let claims = self.verify_actions_token(token)?;
134 if claims["aud"] != "gitdata-actions-runtime" {
135 return Err(CiError::Unauthorized);
136 }
137 self.runtime_context_from_claims(&claims).await
138 }
139
140 async fn runtime_context_from_claims(&self, claims: &Value) -> CiResult<RuntimeContext> {
141 let id = claims["job_id"]
142 .as_str()
143 .and_then(|value| Uuid::parse_str(value).ok())
144 .ok_or(CiError::Unauthorized)?;
145 let job = workflow_job::Entity::find_by_id(id)
146 .one(self.db_write())
147 .await?
148 .ok_or(CiError::Unauthorized)?;
149 if !job.state.is_active() || job.lease_token.as_deref() != claims["lease_token"].as_str() {
150 return Err(CiError::Unauthorized);
151 }
152 let run = workflow_run::Entity::find_by_id(job.run_id)
153 .one(self.db_write())
154 .await?
155 .ok_or(CiError::Unauthorized)?;
156 Ok(RuntimeContext { job, run })
157 }
158
159 pub fn actions_runtime_url(&self) -> CiResult<String> {
160 let configured = &self.config().ci.runtime_base_url;
161 let base = if !configured.is_empty() {
162 configured.as_str()
163 } else if !self.config().webhook.public_base_url.is_empty() {
164 self.config().webhook.public_base_url.as_str()
165 } else {
166 self.config().git.base_url.as_str()
167 };
168 let url = reqwest::Url::parse(base).map_err(|error| {
169 CiError::InvalidArgument(format!("invalid CI runtime origin: {error}"))
170 })?;
171 if url.path() != "/" && !url.path().is_empty() {
172 return Err(CiError::InvalidArgument(
173 "ci.runtime_base_url must be a URL origin without a path prefix".into(),
174 ));
175 }
176 Ok(base.trim_end_matches('/').to_owned())
177 }
178
179 pub fn actions_runtime_token(
180 &self,
181 job: &workflow_job::Model,
182 run: &workflow_run::Model,
183 ) -> CiResult<String> {
184 self.sign_actions_token(&json!({"aud":"gitdata-actions-runtime","job_id":job.id,"run_id":run.id,"repo_id":job.repo_id,"lease_token":job.lease_token,
185 "scp":format!("Actions.Results:{}:{}",run.actions_id.unwrap_or(0),job.actions_id.unwrap_or(0)),"iat":OffsetDateTime::now_utc().unix_timestamp(),
186 "exp":OffsetDateTime::now_utc().unix_timestamp()+self.config().ci.job_timeout_max_secs as i64+600}))
187 }
188
189 fn signed_upload_url(
190 &self,
191 context: &RuntimeContext,
192 kind: &str,
193 id: &str,
194 ) -> CiResult<String> {
195 let token=self.sign_actions_token(&json!({"aud":"gitdata-actions-upload","job_id":context.job.id,"lease_token":context.job.lease_token,"kind":kind,"id":id,"exp":OffsetDateTime::now_utc().unix_timestamp()+3600}))?;
196 Ok(format!(
197 "{}/actions-runtime/uploads/{kind}/{id}?token={token}",
198 self.actions_runtime_url()?
199 ))
200 }
201
202 async fn numeric_actions_id(&self, entity_id: Uuid) -> CiResult<i64> {
203 actions_identity::Entity::find()
204 .filter(actions_identity::Column::EntityId.eq(entity_id))
205 .one(self.db_write())
206 .await?
207 .map(|identity| identity.id)
208 .ok_or(CiError::NotFound)
209 }
210
211 fn check_artifact_scope(&self, context: &RuntimeContext, body: &Value) -> CiResult<()> {
212 let run = field(body, "workflowRunBackendId", "workflow_run_backend_id");
213 if !run.is_empty() && run != context.run.actions_id.unwrap_or(0).to_string() {
214 return Err(CiError::Forbidden);
215 }
216 Ok(())
217 }
218
219 async fn artifact_call(
220 &self,
221 context: &RuntimeContext,
222 method: &str,
223 body: &Value,
224 ) -> CiResult<Value> {
225 self.check_artifact_scope(context, body)?;
226 let name = field(body, "name", "name");
227 match method {
228 "CreateArtifact" => {
229 if name.trim().is_empty() || name.len() > 255 {
230 return Err(CiError::InvalidArgument(
231 "artifact name must be 1-255 characters".into(),
232 ));
233 }
234 let txn = self.db_write().begin().await?;
235 txn.execute_unprepared(&format!(
236 "SELECT pg_advisory_xact_lock({})",
237 context.run.id.as_u128() as i64
238 ))
239 .await?;
240 let existing = workflow_artifact::Entity::find()
241 .filter(workflow_artifact::Column::RunId.eq(context.run.id))
242 .filter(workflow_artifact::Column::Name.eq(&name))
243 .one(&txn)
244 .await?;
245 if let Some(existing) = existing {
246 if existing.job_id == context.job.id
247 && existing.state == ArtifactState::Uploading
248 {
249 let url =
250 self.signed_upload_url(context, "artifact", &existing.id.to_string())?;
251 return Ok(json!({"ok":true,"signedUploadUrl":url}));
252 }
253 return Err(CiError::Conflict(
254 "artifact name already exists in this run".into(),
255 ));
256 }
257 let id = Uuid::now_v7();
258 let now = OffsetDateTime::now_utc();
259 let configured_expiry =
260 now + time::Duration::days(self.config().ci.artifact_retention_days.max(1));
261 let requested_expiry = body
262 .get("expiresAt")
263 .or_else(|| body.get("expires_at"))
264 .and_then(Value::as_str)
265 .and_then(|value| {
266 OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339)
267 .ok()
268 });
269 let expires = requested_expiry
270 .map(|value| value.min(configured_expiry))
271 .unwrap_or(configured_expiry);
272 workflow_artifact::ActiveModel {
273 id: Set(id),
274 run_id: Set(context.run.id),
275 job_id: Set(context.job.id),
276 name: Set(name),
277 object_key: Set(CiService::artifact_object_key(
278 context.run.id,
279 context.job.id,
280 id,
281 )),
282 size: Set(0),
283 sha256: Set(None),
284 mime_type: Set(body
285 .get("mimeType")
286 .or_else(|| body.get("mime_type"))
287 .and_then(|value| value.as_str().or_else(|| value["value"].as_str()))
288 .map(str::to_owned)),
289 state: Set(ArtifactState::Uploading),
290 expires_at: Set(Some(expires)),
291 created_at: Set(now),
292 updated_at: Set(now),
293 }
294 .insert(&txn)
295 .await?;
296 crate::actions::identity(&txn, id, "artifact").await?;
297 txn.commit().await?;
298 Ok(
299 json!({"ok":true,"signedUploadUrl":self.signed_upload_url(context,"artifact",&id.to_string())?}),
300 )
301 }
302 "FinalizeArtifact" => {
303 let row = workflow_artifact::Entity::find()
304 .filter(workflow_artifact::Column::RunId.eq(context.run.id))
305 .filter(workflow_artifact::Column::JobId.eq(context.job.id))
306 .filter(workflow_artifact::Column::Name.eq(name))
307 .one(self.db_write())
308 .await?
309 .ok_or(CiError::NotFound)?;
310 let declared = number(body.get("size"))?;
311 let (size, hash) = self
312 .uploaded_size_and_hash(&row.object_key, self.config().ci.artifact_max_bytes)
313 .await?;
314 if size != declared {
315 return Err(CiError::InvalidArgument(
316 "artifact size does not match upload".into(),
317 ));
318 }
319 let expected = body
320 .get("hash")
321 .and_then(|v| v.as_str().or_else(|| v["value"].as_str()))
322 .map(|v| v.trim_start_matches("sha256:"));
323 if expected.is_some_and(|value| value != hash) {
324 return Err(CiError::InvalidArgument(
325 "artifact digest does not match upload".into(),
326 ));
327 }
328 let id = self.numeric_actions_id(row.id).await?;
329 let mut active: workflow_artifact::ActiveModel = row.into();
330 active.size = Set(size as i64);
331 active.sha256 = Set(Some(hash));
332 active.state = Set(ArtifactState::Ready);
333 active.updated_at = Set(OffsetDateTime::now_utc());
334 active.update(self.db_write()).await?;
335 Ok(json!({"ok":true,"artifactId":id.to_string()}))
336 }
337 "ListArtifacts" => {
338 let rows = workflow_artifact::Entity::find()
339 .filter(workflow_artifact::Column::RunId.eq(context.run.id))
340 .filter(workflow_artifact::Column::State.eq(ArtifactState::Ready))
341 .all(self.db_write())
342 .await?;
343 let name_filter = field(body, "nameFilter", "name_filter");
344 let id_filter = body
345 .get("idFilter")
346 .or_else(|| body.get("id_filter"))
347 .and_then(|v| v.as_str().or_else(|| v["value"].as_str()));
348 let mut artifacts = Vec::new();
349 for row in rows {
350 if row
351 .expires_at
352 .is_some_and(|expiry| expiry <= OffsetDateTime::now_utc())
353 || (!name_filter.is_empty() && row.name != name_filter)
354 {
355 continue;
356 }
357 let id = self.numeric_actions_id(row.id).await?;
358 if id_filter.is_some_and(|filter| filter != id.to_string()) {
359 continue;
360 }
361 let job = workflow_job::Entity::find_by_id(row.job_id)
362 .one(self.db_write())
363 .await?
364 .ok_or(CiError::NotFound)?;
365 artifacts.push(json!({"workflowRunBackendId":context.run.actions_id.unwrap_or(0).to_string(),"workflowJobRunBackendId":job.actions_id.unwrap_or(0).to_string(),"databaseId":id.to_string(),"name":row.name,"size":row.size.to_string(),"createdAt":row.created_at.format(&time::format_description::well_known::Rfc3339).unwrap_or_default(),"hash":row.sha256.map(|hash|format!("sha256:{hash}")),"mimeType":row.mime_type}));
366 }
367 Ok(json!({"artifacts":artifacts}))
368 }
369 "GetSignedArtifactURL" | "DeleteArtifact" => {
370 let row = workflow_artifact::Entity::find()
371 .filter(workflow_artifact::Column::RunId.eq(context.run.id))
372 .filter(workflow_artifact::Column::Name.eq(&name))
373 .one(self.db_write())
374 .await?
375 .ok_or(CiError::NotFound)?;
376 if method == "DeleteArtifact" {
377 let id = self.numeric_actions_id(row.id).await?;
378 self.storage().client().delete(&row.object_key).await?;
379 workflow_artifact::Entity::delete_by_id(row.id)
380 .exec(self.db_write())
381 .await?;
382 Ok(json!({"ok":true,"artifactId":id.to_string()}))
383 } else {
384 if row.state != ArtifactState::Ready
385 || row
386 .expires_at
387 .is_some_and(|at| at <= OffsetDateTime::now_utc())
388 {
389 return Err(CiError::NotFound);
390 }
391 Ok(
392 json!({"signedUrl":self.storage().client().presign_get(&row.object_key,900)?}),
393 )
394 }
395 }
396 _ => Err(CiError::InvalidArgument(format!(
397 "unknown artifact method `{method}`"
398 ))),
399 }
400 }
401
402 async fn uploaded_size_and_hash(&self, key: &str, max: u64) -> CiResult<(u64, String)> {
403 let mut stream = self.storage().client().get_stream(key).await?;
404 let mut hash = Sha256::new();
405 let mut size = 0;
406 while let Some(chunk) = stream.next().await {
407 let chunk = chunk?;
408 size += chunk.len() as u64;
409 if size > max {
410 return Err(CiError::InvalidArgument(
411 "upload exceeds configured size limit".into(),
412 ));
413 }
414 hash.update(&chunk);
415 }
416 Ok((
417 size,
418 hash.finalize()
419 .iter()
420 .map(|byte| format!("{byte:02x}"))
421 .collect(),
422 ))
423 }
424
425 async fn cache_reserve(
426 &self,
427 context: &RuntimeContext,
428 body: &Value,
429 ) -> CiResult<actions_cache::Model> {
430 if context.run.event_payload["pull_request"]["head"]["repo"]["fork"]
431 .as_bool()
432 .unwrap_or(false)
433 {
434 return Err(CiError::Forbidden);
435 }
436 let key = field(body, "key", "key");
437 let version = field(body, "version", "version");
438 if key.is_empty() || key.len() > 512 || version.is_empty() || version.len() > 512 {
439 return Err(CiError::InvalidArgument(
440 "cache key and version must be 1-512 characters".into(),
441 ));
442 }
443 let txn = self.db_write().begin().await?;
444 txn.execute_unprepared(&format!(
445 "SELECT pg_advisory_xact_lock({})",
446 context.job.repo_id.as_u128() as i64
447 ))
448 .await?;
449 if let Some(row) = actions_cache::Entity::find()
450 .filter(actions_cache::Column::RepoId.eq(context.job.repo_id))
451 .filter(actions_cache::Column::GitRef.eq(&context.run.git_ref))
452 .filter(actions_cache::Column::Key.eq(&key))
453 .filter(actions_cache::Column::Version.eq(&version))
454 .one(&txn)
455 .await?
456 {
457 if row.job_id == context.job.id && !row.ready {
458 return Ok(row);
459 }
460 return Err(CiError::Conflict("cache entry already exists".into()));
461 }
462 let now = OffsetDateTime::now_utc();
463 let object_key = format!("ci/caches/{}/{}", context.job.repo_id, Uuid::now_v7());
464 let row = actions_cache::ActiveModel {
465 repo_id: Set(context.job.repo_id),
466 job_id: Set(context.job.id),
467 git_ref: Set(context.run.git_ref.clone()),
468 key: Set(key),
469 version: Set(version),
470 object_key: Set(object_key),
471 size: Set(0),
472 ready: Set(false),
473 created_at: Set(now),
474 last_accessed_at: Set(now),
475 expires_at: Set(
476 now + time::Duration::days(self.config().ci.cache_retention_days.max(1))
477 ),
478 ..Default::default()
479 }
480 .insert(&txn)
481 .await?;
482 txn.commit().await?;
483 Ok(row)
484 }
485
486 async fn cache_lookup(
487 &self,
488 context: &RuntimeContext,
489 key: &str,
490 restore_keys: &[String],
491 version: &str,
492 ) -> CiResult<Option<actions_cache::Model>> {
493 let repo = models::repo::repos::Entity::find_by_id(context.job.repo_id)
494 .one(self.db_write())
495 .await?
496 .ok_or(CiError::NotFound)?;
497 let mut refs = vec![context.run.git_ref.clone()];
498 if let Some(base) = &context.run.base_ref {
499 refs.push(format!("refs/heads/{base}"))
500 }
501 refs.push(format!("refs/heads/{}", repo.default_branch));
502 refs.dedup();
503 let rows = actions_cache::Entity::find()
504 .filter(actions_cache::Column::RepoId.eq(repo.id))
505 .filter(actions_cache::Column::Version.eq(version))
506 .filter(actions_cache::Column::Ready.eq(true))
507 .filter(actions_cache::Column::ExpiresAt.gt(OffsetDateTime::now_utc()))
508 .order_by_desc(actions_cache::Column::CreatedAt)
509 .all(self.db_write())
510 .await?;
511 for reference in refs {
512 let entries: Vec<_> = rows.iter().filter(|row| row.git_ref == reference).collect();
513 let matched = entries
514 .iter()
515 .find(|row| row.key == key)
516 .copied()
517 .or_else(|| entries.iter().find(|row| row.key.starts_with(key)).copied())
518 .or_else(|| {
519 restore_keys.iter().find_map(|prefix| {
520 entries
521 .iter()
522 .find(|row| row.key.starts_with(prefix))
523 .copied()
524 })
525 });
526 if let Some(row) = matched {
527 let mut active: actions_cache::ActiveModel = row.clone().into();
528 active.last_accessed_at = Set(OffsetDateTime::now_utc());
529 active.expires_at = Set(OffsetDateTime::now_utc()
530 + time::Duration::days(self.config().ci.cache_retention_days.max(1)));
531 return Ok(Some(active.update(self.db_write()).await?));
532 }
533 }
534 Ok(None)
535 }
536
537 async fn cache_finalize(
538 &self,
539 context: &RuntimeContext,
540 id: Option<i64>,
541 body: &Value,
542 ) -> CiResult<actions_cache::Model> {
543 let mut query = actions_cache::Entity::find()
544 .filter(actions_cache::Column::RepoId.eq(context.job.repo_id))
545 .filter(actions_cache::Column::JobId.eq(context.job.id));
546 if let Some(id) = id {
547 query = query.filter(actions_cache::Column::Id.eq(id))
548 } else {
549 query = query
550 .filter(actions_cache::Column::Key.eq(field(body, "key", "key")))
551 .filter(actions_cache::Column::Version.eq(field(body, "version", "version")))
552 }
553 let row = query.one(self.db_write()).await?.ok_or(CiError::NotFound)?;
554 let declared = number(
555 body.get("sizeBytes")
556 .or_else(|| body.get("size_bytes"))
557 .or_else(|| body.get("size")),
558 )?;
559 let (size, _) = self
560 .uploaded_size_and_hash(&row.object_key, self.config().ci.cache_max_bytes)
561 .await?;
562 if size != declared {
563 return Err(CiError::InvalidArgument(
564 "cache size does not match upload".into(),
565 ));
566 }
567 let mut active: actions_cache::ActiveModel = row.into();
568 active.size = Set(size as i64);
569 active.ready = Set(true);
570 active.last_accessed_at = Set(OffsetDateTime::now_utc());
571 Ok(active.update(self.db_write()).await?)
572 }
573
574 async fn assemble_blocks(&self, key: &str, blocks: &[String], max: u64) -> CiResult<()> {
575 let temporary = std::env::temp_dir().join(format!("gitdata-ci-upload-{}", Uuid::now_v7()));
576 let result: CiResult<()> = async {
577 let mut file = tokio::fs::OpenOptions::new()
578 .create_new(true)
579 .write(true)
580 .read(true)
581 .open(&temporary)
582 .await
583 .map_err(|error| CiError::Internal(error.to_string()))?;
584 let mut size = 0u64;
585 for block in blocks {
586 let data = self
587 .storage()
588 .client()
589 .get(&format!(
590 "{key}.blocks/{}",
591 crate::secrets::sha256_hex(block.as_bytes())
592 ))
593 .await?;
594 size += data.len() as u64;
595 if size > max {
596 return Err(CiError::InvalidArgument(
597 "upload exceeds configured size limit".into(),
598 ));
599 }
600 file.write_all(&data)
601 .await
602 .map_err(|error| CiError::Internal(error.to_string()))?;
603 }
604 file.seek(std::io::SeekFrom::Start(0))
605 .await
606 .map_err(|error| CiError::Internal(error.to_string()))?;
607 self.storage()
608 .client()
609 .put_stream(key, &mut file, "application/octet-stream")
610 .await?;
611 for block in blocks {
612 self.storage()
613 .client()
614 .delete(&format!(
615 "{key}.blocks/{}",
616 crate::secrets::sha256_hex(block.as_bytes())
617 ))
618 .await?;
619 }
620 Ok(())
621 }
622 .await;
623 let _ = tokio::fs::remove_file(temporary).await;
624 result
625 }
626}
627
628fn field(value: &Value, camel: &str, snake: &str) -> String {
629 value
630 .get(camel)
631 .or_else(|| value.get(snake))
632 .and_then(|v| v.as_str().or_else(|| v["value"].as_str()))
633 .unwrap_or_default()
634 .to_owned()
635}
636fn number(value: Option<&Value>) -> CiResult<u64> {
637 value
638 .and_then(|value| {
639 value
640 .as_u64()
641 .or_else(|| value.as_str().and_then(|text| text.parse().ok()))
642 })
643 .ok_or_else(|| CiError::InvalidArgument("expected a nonnegative size".into()))
644}
645
646async fn artifact_rpc(
647 State(service): State<CiService>,
648 Path(method): Path<String>,
649 headers: HeaderMap,
650 Json(body): Json<Value>,
651) -> Result<Json<Value>, RuntimeError> {
652 let context = service.runtime_context(&headers).await?;
653 Ok(Json(service.artifact_call(&context, &method, &body).await?))
654}
655
656#[derive(Deserialize)]
657struct OidcQuery {
658 audience: Option<String>,
659}
660
661async fn oidc_token(
662 State(service): State<CiService>,
663 headers: HeaderMap,
664 Query(query): Query<OidcQuery>,
665) -> Result<Json<Value>, RuntimeError> {
666 let context = service.runtime_context(&headers).await?;
667 if context
668 .job
669 .actions_context
670 .as_ref()
671 .and_then(|context| context["permissions"]["id-token"].as_str())
672 != Some("write")
673 {
674 return Err(CiError::Forbidden.into());
675 }
676 if service.config().ci.oidc_private_key.is_empty() {
677 return Err(CiError::InvalidArgument(
678 "ci.oidc_private_key is required to issue OIDC tokens".into(),
679 )
680 .into());
681 }
682 let github = service.actions_github_context(&context.run).await?;
683 let repository = github["repository"].as_str().unwrap_or_default();
684 let sub = if let Some(environment) = &context.job.environment {
685 format!(
686 "repo:{repository}:environment:{}",
687 environment.replace(':', "%3A")
688 )
689 } else if context.run.pr_number.is_some() {
690 format!("repo:{repository}:pull_request")
691 } else {
692 format!("repo:{repository}:ref:{}", context.run.git_ref)
693 };
694 let now = OffsetDateTime::now_utc().unix_timestamp();
695 let issuer = service.actions_runtime_url()?;
696 let mut claims = github;
697 claims["iss"] = json!(issuer);
698 claims["sub"] = json!(sub);
699 claims["aud"] = json!(query.audience.unwrap_or_else(|| format!(
700 "{}/{}",
701 issuer,
702 claims["repository_owner"].as_str().unwrap_or_default()
703 )));
704 claims["iat"] = json!(now);
705 claims["nbf"] = json!(now - 5);
706 claims["exp"] = json!(now + 300);
707 claims["jti"] = json!(Uuid::now_v7());
708 claims.as_object_mut().map(|value| value.remove("token"));
709 let value = service
710 .actions_engine(
711 json!({"op":"oidc_sign","token":service.config().ci.oidc_private_key,"value":claims}),
712 )
713 .await?;
714 Ok(Json(json!({"value":value})))
715}
716
717async fn oidc_configuration(State(service): State<CiService>) -> Result<Json<Value>, RuntimeError> {
718 let issuer = service.actions_runtime_url()?;
719 Ok(Json(
720 json!({"issuer":issuer,"jwks_uri":format!("{issuer}/.well-known/jwks.json"),"id_token_signing_alg_values_supported":["RS256"],"subject_types_supported":["public"],"response_types_supported":["id_token"]}),
721 ))
722}
723
724async fn oidc_jwks(State(service): State<CiService>) -> Result<Json<Value>, RuntimeError> {
725 if service.config().ci.oidc_private_key.is_empty() {
726 return Ok(Json(json!({"keys":[]})));
727 }
728 Ok(Json(
729 service
730 .actions_engine(json!({"op":"oidc_jwks","token":service.config().ci.oidc_private_key}))
731 .await?,
732 ))
733}
734
735async fn cache_rpc(
736 State(service): State<CiService>,
737 Path(method): Path<String>,
738 headers: HeaderMap,
739 Json(body): Json<Value>,
740) -> Result<Json<Value>, RuntimeError> {
741 let context = service.runtime_context(&headers).await?;
742 let result = match method.as_str() {
743 "CreateCacheEntry" => match service.cache_reserve(&context, &body).await {
744 Ok(row) => {
745 json!({"ok":true,"signedUploadUrl":service.signed_upload_url(&context,"cache",&row.id.to_string())?,"message":""})
746 }
747 Err(CiError::Conflict(message)) => {
748 json!({"ok":false,"signedUploadUrl":"","message":message})
749 }
750 Err(error) => return Err(error.into()),
751 },
752 "FinalizeCacheEntryUpload" => {
753 let row = service.cache_finalize(&context, None, &body).await?;
754 json!({"ok":true,"entryId":row.id.to_string(),"message":""})
755 }
756 "GetCacheEntryDownloadURL" => {
757 let restore = body
758 .get("restoreKeys")
759 .or_else(|| body.get("restore_keys"))
760 .map(|value| ci_protocol::actions::strings(Some(value)))
761 .unwrap_or_default();
762 match service
763 .cache_lookup(
764 &context,
765 &field(&body, "key", "key"),
766 &restore,
767 &field(&body, "version", "version"),
768 )
769 .await?
770 {
771 Some(row) => {
772 json!({"ok":true,"signedDownloadUrl":service.storage().client().presign_get(&row.object_key,900)? ,"matchedKey":row.key})
773 }
774 None => json!({"ok":false,"signedDownloadUrl":"","matchedKey":""}),
775 }
776 }
777 _ => {
778 return Err(
779 CiError::InvalidArgument(format!("unknown cache method `{method}`")).into(),
780 );
781 }
782 };
783 Ok(Json(result))
784}
785
786#[derive(Deserialize)]
787struct UploadQuery {
788 token: String,
789 comp: Option<String>,
790 blockid: Option<String>,
791}
792
793async fn upload_blob(
794 State(service): State<CiService>,
795 Path((kind, id)): Path<(String, String)>,
796 Query(query): Query<UploadQuery>,
797 body: Bytes,
798) -> Result<Response, RuntimeError> {
799 let claims = service.verify_actions_token(&query.token)?;
800 if claims["aud"] != "gitdata-actions-upload" || claims["kind"] != kind || claims["id"] != id {
801 return Err(CiError::Forbidden.into());
802 }
803 let context = service.runtime_context_from_claims(&claims).await?;
804 let (key, max) = if kind == "artifact" {
805 let id = Uuid::parse_str(&id).map_err(|_| CiError::NotFound)?;
806 let row = workflow_artifact::Entity::find_by_id(id)
807 .one(service.db_write())
808 .await?
809 .ok_or(CiError::NotFound)?;
810 if row.job_id != context.job.id || row.state != ArtifactState::Uploading {
811 return Err(CiError::Forbidden.into());
812 }
813 (row.object_key, service.config().ci.artifact_max_bytes)
814 } else if kind == "cache" {
815 let id = id.parse::<i64>().map_err(|_| CiError::NotFound)?;
816 let row = actions_cache::Entity::find_by_id(id)
817 .one(service.db_write())
818 .await?
819 .ok_or(CiError::NotFound)?;
820 if row.job_id != context.job.id || row.ready {
821 return Err(CiError::Forbidden.into());
822 }
823 (row.object_key, service.config().ci.cache_max_bytes)
824 } else {
825 return Err(CiError::NotFound.into());
826 };
827 match query.comp.as_deref() {
828 Some("block") => {
829 let block = query
830 .blockid
831 .ok_or_else(|| CiError::InvalidArgument("blockid is required".into()))?;
832 if block.len() > 256 {
833 return Err(CiError::InvalidArgument("blockid is too long".into()).into());
834 }
835 service
836 .storage()
837 .client()
838 .put(
839 &format!(
840 "{key}.blocks/{}",
841 crate::secrets::sha256_hex(block.as_bytes())
842 ),
843 &body,
844 )
845 .await
846 .map_err(CiError::from)?;
847 }
848 Some("blocklist") => {
849 let text = std::str::from_utf8(&body)
850 .map_err(|_| CiError::InvalidArgument("blocklist is not UTF-8".into()))?;
851 let mut blocks = Vec::new();
852 for part in text.split('<') {
853 if let Some(value) = part
854 .strip_prefix("Latest>")
855 .or_else(|| part.strip_prefix("Uncommitted>"))
856 .or_else(|| part.strip_prefix("Committed>"))
857 {
858 blocks.push(value.split('<').next().unwrap_or_default().to_owned());
859 }
860 }
861 if blocks.len() > 50000 {
862 return Err(CiError::InvalidArgument("too many upload blocks".into()).into());
863 }
864 service.assemble_blocks(&key, &blocks, max).await?;
865 }
866 None => {
867 if body.len() as u64 > max {
868 return Err(CiError::InvalidArgument(
869 "upload exceeds configured size limit".into(),
870 )
871 .into());
872 }
873 service
874 .storage()
875 .client()
876 .put(&key, &body)
877 .await
878 .map_err(CiError::from)?;
879 }
880 _ => return Err(CiError::InvalidArgument("unsupported upload operation".into()).into()),
881 }
882 Ok((
883 StatusCode::CREATED,
884 [
885 ("x-ms-request-id", Uuid::now_v7().to_string()),
886 ("x-ms-version", "2023-11-03".into()),
887 ("etag", format!("\"{}\"", Uuid::now_v7())),
888 ],
889 )
890 .into_response())
891}
892
893#[derive(Deserialize)]
894struct LegacyLookup {
895 keys: String,
896 version: String,
897}
898
899async fn legacy_cache_lookup(
900 State(service): State<CiService>,
901 headers: HeaderMap,
902 Query(query): Query<LegacyLookup>,
903) -> Result<Response, RuntimeError> {
904 let context = service.runtime_context(&headers).await?;
905 let keys: Vec<String> = query.keys.split(',').map(str::to_owned).collect();
906 match service.cache_lookup(&context,keys.first().map(String::as_str).unwrap_or_default(),keys.get(1..).unwrap_or_default(),&query.version).await?{
907 Some(row)=>Ok(Json(json!({"cacheKey":row.key,"scope":row.git_ref,"creationTime":row.created_at.format(&time::format_description::well_known::Rfc3339).unwrap_or_default(),"archiveLocation":service.storage().client().presign_get(&row.object_key,900)?})).into_response()),
908 None=>Ok(StatusCode::NO_CONTENT.into_response()),
909 }
910}
911
912async fn legacy_cache_reserve(
913 State(service): State<CiService>,
914 headers: HeaderMap,
915 Json(body): Json<Value>,
916) -> Result<Json<Value>, RuntimeError> {
917 let context = service.runtime_context(&headers).await?;
918 let row = service.cache_reserve(&context, &body).await?;
919 Ok(Json(json!({"cacheId":row.id})))
920}
921
922async fn legacy_cache_upload(
923 State(service): State<CiService>,
924 Path(id): Path<i64>,
925 headers: HeaderMap,
926 body: Bytes,
927) -> Result<StatusCode, RuntimeError> {
928 let context = service.runtime_context(&headers).await?;
929 let row = actions_cache::Entity::find_by_id(id)
930 .one(service.db_write())
931 .await
932 .map_err(CiError::from)?
933 .ok_or(CiError::NotFound)?;
934 if row.job_id != context.job.id || row.ready {
935 return Err(CiError::Forbidden.into());
936 }
937 let range = headers
938 .get("content-range")
939 .and_then(|value| value.to_str().ok())
940 .ok_or_else(|| CiError::InvalidArgument("content-range is required".into()))?;
941 let span = range
942 .strip_prefix("bytes ")
943 .and_then(|value| value.split('/').next())
944 .and_then(|value| value.split_once('-'))
945 .ok_or_else(|| CiError::InvalidArgument("invalid content-range".into()))?;
946 let start = span
947 .0
948 .parse::<u64>()
949 .map_err(|_| CiError::InvalidArgument("invalid content-range".into()))?;
950 let end = span
951 .1
952 .parse::<u64>()
953 .map_err(|_| CiError::InvalidArgument("invalid content-range".into()))?;
954 if end < start
955 || end - start + 1 != body.len() as u64
956 || end >= service.config().ci.cache_max_bytes
957 {
958 return Err(CiError::InvalidArgument("cache upload range is invalid".into()).into());
959 }
960 service
961 .storage()
962 .client()
963 .put(&format!("{}.ranges/{start:020}", row.object_key), &body)
964 .await
965 .map_err(CiError::from)?;
966 Ok(StatusCode::NO_CONTENT)
967}
968
969async fn legacy_cache_finalize(
970 State(service): State<CiService>,
971 Path(id): Path<i64>,
972 headers: HeaderMap,
973 Json(body): Json<Value>,
974) -> Result<StatusCode, RuntimeError> {
975 let context = service.runtime_context(&headers).await?;
976 let row = actions_cache::Entity::find_by_id(id)
977 .one(service.db_write())
978 .await
979 .map_err(CiError::from)?
980 .ok_or(CiError::NotFound)?;
981 if row.job_id != context.job.id {
982 return Err(CiError::Forbidden.into());
983 }
984 let mut ranges = service
985 .storage()
986 .client()
987 .list(&format!("{}.ranges/", row.object_key))
988 .await
989 .map_err(CiError::from)?;
990 ranges.sort_by(|a, b| a.key.cmp(&b.key));
991 let temporary = std::env::temp_dir().join(format!("gitdata-cache-upload-{}", Uuid::now_v7()));
992 let assembled: CiResult<()> = async {
993 let mut file = tokio::fs::OpenOptions::new()
994 .create_new(true)
995 .write(true)
996 .read(true)
997 .open(&temporary)
998 .await
999 .map_err(|error| CiError::Internal(error.to_string()))?;
1000 let mut expected = 0;
1001 for range in &ranges {
1002 let offset = range
1003 .key
1004 .rsplit('/')
1005 .next()
1006 .and_then(|value| value.parse::<u64>().ok())
1007 .ok_or_else(|| CiError::InvalidArgument("invalid cache segment".into()))?;
1008 if offset != expected {
1009 return Err(CiError::InvalidArgument(
1010 "cache segments overlap or have gaps".into(),
1011 ));
1012 }
1013 let data = service.storage().client().get(&range.key).await?;
1014 expected += data.len() as u64;
1015 file.write_all(&data)
1016 .await
1017 .map_err(|error| CiError::Internal(error.to_string()))?;
1018 }
1019 file.seek(std::io::SeekFrom::Start(0))
1020 .await
1021 .map_err(|error| CiError::Internal(error.to_string()))?;
1022 service
1023 .storage()
1024 .client()
1025 .put_stream(&row.object_key, &mut file, "application/octet-stream")
1026 .await?;
1027 Ok(())
1028 }
1029 .await;
1030 let _ = tokio::fs::remove_file(&temporary).await;
1031 assembled?;
1032 service.cache_finalize(&context, Some(id), &body).await?;
1033 for range in ranges {
1034 service
1035 .storage()
1036 .client()
1037 .delete(&range.key)
1038 .await
1039 .map_err(CiError::from)?;
1040 }
1041 Ok(StatusCode::NO_CONTENT)
1042}
Deleted lib/ci/src/actions_scheduler.rs
@@ -1,904 +0,0 @@
1use std::collections::BTreeMap;
2
3use ci_protocol::actions::{ActionsWorkflow, EngineExpansion, NeedResult, strings};
4use models::ci::workflow_job::WorkflowJobState as JobState;
5use models::ci::{workflow_job, workflow_run};
6use sea_orm::{
7 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set, TransactionTrait,
8};
9use serde_json::{Value, json};
10use time::OffsetDateTime;
11use uuid::Uuid;
12
13use crate::{
14 CiService,
15 errors::{CiError, CiResult},
16};
17
18impl CiService {
19 pub async fn claim_actions_job(
20 &self,
21 runner: &models::ci::runner::Model,
22 job: &workflow_job::Model,
23 labels: &[String],
24 ) -> CiResult<bool> {
25 use sea_orm::{ConnectionTrait, PaginatorTrait};
26 let capabilities: Vec<String> = runner
27 .capabilities
28 .as_ref()
29 .and_then(|value| serde_json::from_value(value.clone()).ok())
30 .unwrap_or_default();
31 let context = job.actions_context.as_ref().unwrap_or(&Value::Null);
32 if let Some(group) = context["instance"]["group"].as_str()
33 && !capabilities
34 .iter()
35 .any(|cap| cap == &format!("runner-group:{group}"))
36 {
37 return Ok(false);
38 }
39 let definition =
40 &context["workflow"]["document"]["jobs"][context["job"].as_str().unwrap_or_default()];
41 if (!definition["container"].is_null()
42 || definition["services"]
43 .as_object()
44 .is_some_and(|value| !value.is_empty())
45 || definition["steps"].as_array().is_some_and(|steps| {
46 steps.iter().any(|step| {
47 step["uses"]
48 .as_str()
49 .is_some_and(|value| value.starts_with("docker://"))
50 })
51 }))
52 && !capabilities.iter().any(|value| value == "docker")
53 {
54 return Ok(false);
55 }
56 if let Some(environment) = job.environment.as_deref()
57 && let Some(environment) = self
58 .find_actions_environment(job.space_id, job.repo_id, environment)
59 .await?
60 {
61 let required: Vec<String> =
62 serde_json::from_value(environment.required_labels).unwrap_or_default();
63 if required.iter().any(|label| !labels.contains(label)) {
64 return Ok(false);
65 }
66 }
67 let txn = self.db_write().begin().await?;
68 for lock in [runner.id.as_u128() as i64, job.repo_id.as_u128() as i64] {
69 txn.execute_unprepared(&format!("SELECT pg_advisory_xact_lock({lock})"))
70 .await?;
71 }
72 let current = workflow_job::Entity::find_by_id(job.id)
73 .lock_exclusive()
74 .one(&txn)
75 .await?
76 .ok_or(CiError::NotFound)?;
77 if current.state != JobState::Queued {
78 return Ok(false);
79 }
80 let active = workflow_job::Entity::find()
81 .filter(workflow_job::Column::RunnerId.eq(runner.id))
82 .filter(workflow_job::Column::State.is_in([JobState::Claimed, JobState::Running]))
83 .count(&txn)
84 .await?;
85 if active >= runner.capacity.max(1) as u64 || !self.actions_can_claim(&current).await? {
86 return Ok(false);
87 }
88 let now = OffsetDateTime::now_utc();
89 let mut active: workflow_job::ActiveModel = current.into();
90 active.state = Set(JobState::Claimed);
91 active.runner_id = Set(Some(runner.id));
92 active.lease_token = Set(Some(crate::secrets::random_secret(24)));
93 active.lease_expires_at = Set(Some(
94 now + time::Duration::seconds(self.config().ci.lease_secs as i64),
95 ));
96 active.attempt = Set(job.attempt + 1);
97 active.updated_at = Set(now);
98 active.update(&txn).await?;
99 txn.commit().await?;
100 Ok(true)
101 }
102
103 pub async fn actions_can_claim(&self, job: &workflow_job::Model) -> CiResult<bool> {
104 use sea_orm::PaginatorTrait;
105 let context = job.actions_context.as_ref().unwrap_or(&Value::Null);
106 if context["cancel_requested"].as_bool().unwrap_or(false) {
107 return Ok(false);
108 }
109 if let Some(parent) = job.logical_job_id {
110 let active = workflow_job::Entity::find()
111 .filter(workflow_job::Column::LogicalJobId.eq(parent))
112 .filter(workflow_job::Column::State.is_in([JobState::Claimed, JobState::Running]))
113 .filter(workflow_job::Column::IsTemplate.eq(false))
114 .count(self.db_write())
115 .await?;
116 if active
117 >= context["instance"]["max_parallel"]
118 .as_u64()
119 .unwrap_or(1)
120 .max(1)
121 {
122 return Ok(false);
123 }
124 }
125 let run = workflow_run::Entity::find_by_id(job.run_id)
126 .one(self.db_write())
127 .await?
128 .ok_or(CiError::NotFound)?;
129 if let Some(group) = &run.concurrency_group {
130 let active = workflow_run::Entity::find()
131 .filter(workflow_run::Column::RepoId.eq(run.repo_id))
132 .filter(workflow_run::Column::ConcurrencyGroup.eq(group))
133 .filter(workflow_run::Column::Id.ne(run.id))
134 .filter(workflow_run::Column::State.is_in([
135 models::ci::workflow_run::WorkflowRunState::Running,
136 models::ci::workflow_run::WorkflowRunState::CancelRequested,
137 ]))
138 .count(self.db_write())
139 .await?;
140 if active > 0 {
141 return Ok(false);
142 }
143 }
144 let concurrency = &context["instance"]["concurrency"];
145 let group = concurrency
146 .as_str()
147 .or_else(|| concurrency["group"].as_str());
148 if let Some(group) = group {
149 let active = workflow_job::Entity::find()
150 .filter(workflow_job::Column::RepoId.eq(job.repo_id))
151 .filter(workflow_job::Column::State.is_in([JobState::Claimed, JobState::Running]))
152 .filter(workflow_job::Column::IsTemplate.eq(false))
153 .all(self.db_write())
154 .await?;
155 if active.iter().any(|other| {
156 other.actions_context.as_ref().is_some_and(|ctx| {
157 ctx["instance"]["concurrency"]
158 .as_str()
159 .or_else(|| ctx["instance"]["concurrency"]["group"].as_str())
160 .is_some_and(|other| other.eq_ignore_ascii_case(group))
161 })
162 }) {
163 return Ok(false);
164 }
165 }
166 self.actions_environment_available(job).await
167 }
168
169 pub async fn actions_github_context(&self, run: &workflow_run::Model) -> CiResult<Value> {
170 let repo = models::repo::repos::Entity::find_by_id(run.repo_id)
171 .one(self.db_read())
172 .await?
173 .ok_or(CiError::NotFound)?;
174 let space = self.space_of_repo(&repo).await?;
175 let actor = if let Some(id) = run.trigger_user_id {
176 models::users::user::Entity::find_by_id(id)
177 .one(self.db_read())
178 .await?
179 .map(|user| user.username)
180 .unwrap_or_else(|| id.to_string())
181 } else {
182 space.lower_name.clone()
183 };
184 Ok(
185 json!({"workflow":run.workflow_name,"workflow_ref":format!("{}/{}@{}",space.lower_name,run.workflow_file,run.git_ref),
186 "run_id":run.actions_id.unwrap_or(0).to_string(),"run_number":run.run_number.unwrap_or(1).to_string(),"run_attempt":(run.attempt+1).to_string(),
187 "repository":format!("{}/{}",space.lower_name,repo.name),"repository_owner":space.lower_name,"repository_id":repo.id.to_string(),
188 "actor":actor,"event_name":run.event.as_str(),"sha":run.commit_sha,"ref":run.git_ref,
189 "head_ref":run.branch.clone().unwrap_or_default(),"base_ref":run.base_ref.clone().unwrap_or_default(),
190 "server_url":self.config().git.base_url.trim_end_matches('/'),"api_url":format!("{}/api/v1",self.config().git.base_url.trim_end_matches('/')),
191 "graphql_url":"","retention_days":self.config().ci.artifact_retention_days.to_string()}),
192 )
193 }
194
195 pub async fn actions_variables(&self, repo_id: Uuid) -> CiResult<BTreeMap<String, String>> {
196 let rows = models::ci::variable::Entity::find()
197 .filter(models::ci::variable::Column::RepoId.eq(repo_id))
198 .all(self.db_read())
199 .await?;
200 Ok(rows.into_iter().map(|row| (row.name, row.value)).collect())
201 }
202
203 pub async fn actions_request(
204 &self,
205 run: &workflow_run::Model,
206 context: &Value,
207 needs: &BTreeMap<String, NeedResult>,
208 op: &str,
209 ) -> CiResult<Value> {
210 let workflow: ActionsWorkflow = serde_json::from_value(context["workflow"].clone())?;
211 Ok(
212 json!({"op":op,"source":workflow.source,"file":workflow.file,"job":context["job"],
213 "inputs":context.get("inputs").cloned().unwrap_or(json!({})),"matrix":context.get("instance").and_then(|i|i.get("matrix")).cloned().unwrap_or(json!({})),
214 "needs":needs,"github":self.actions_github_context(run).await?,"event":run.event_payload,
215 "vars":self.actions_variables(run.repo_id).await?}),
216 )
217 }
218
219 pub fn actions_needs(
220 job: &workflow_job::Model,
221 jobs: &[workflow_job::Model],
222 ) -> BTreeMap<String, NeedResult> {
223 let scope = job
224 .actions_context
225 .as_ref()
226 .and_then(|v| v["scope"].as_str())
227 .unwrap_or_default();
228 strings(Some(&job.needs))
229 .into_iter()
230 .filter_map(|key| {
231 let row = jobs
232 .iter()
233 .find(|row| row.job_key == key && row.is_template)?;
234 Some((
235 key.strip_prefix(scope).unwrap_or(&key).to_owned(),
236 NeedResult {
237 result: actions_result(row).into(),
238 outputs: row
239 .outputs
240 .as_ref()
241 .and_then(|v| serde_json::from_value(v.clone()).ok())
242 .unwrap_or_default(),
243 },
244 ))
245 })
246 .collect()
247 }
248
249 pub async fn advance_actions_run(&self, run: &workflow_run::Model) -> CiResult<u64> {
250 self.initialize_actions_metadata(run).await?;
251 let jobs = workflow_job::Entity::find()
252 .filter(workflow_job::Column::RunId.eq(run.id))
253 .all(self.db_write())
254 .await?;
255 let mut changed = 0;
256 for template in jobs
257 .iter()
258 .filter(|job| job.is_template && !job.state.is_terminal())
259 {
260 if template.state == JobState::Running {
261 if self
262 .aggregate_actions_template(run, template, &jobs)
263 .await?
264 {
265 changed += 1
266 }
267 continue;
268 }
269 if template.state != JobState::Pending {
270 continue;
271 }
272 let dependencies = strings(Some(&template.needs));
273 if dependencies.iter().any(|key| {
274 !jobs
275 .iter()
276 .any(|job| job.is_template && job.job_key == *key && job.state.is_terminal())
277 }) {
278 continue;
279 }
280 match self.expand_actions_template(run, template, &jobs).await {
281 Ok(count) => changed += count,
282 Err(error) => {
283 let mut active: workflow_job::ActiveModel = template.clone().into();
284 active.state = Set(JobState::Failure);
285 active.outcome = Set(Some("failure".into()));
286 active.error = Set(Some(error.to_string()));
287 active.finished_at = Set(Some(OffsetDateTime::now_utc()));
288 active.updated_at = Set(OffsetDateTime::now_utc());
289 active.update(self.db_write()).await?;
290 tracing::warn!(job=%template.id,%error,"Actions job planning failed");
291 changed += 1;
292 }
293 }
294 }
295 for failed in jobs.iter().filter(|job| {
296 !job.is_template
297 && job.state.is_terminal()
298 && !matches!(job.state, JobState::Success | JobState::Skipped)
299 }) {
300 let context = failed.actions_context.as_ref().unwrap_or(&Value::Null);
301 if !context["instance"]["fail_fast"].as_bool().unwrap_or(true)
302 || context["instance"]["continue_on_error"]
303 .as_bool()
304 .unwrap_or(false)
305 {
306 continue;
307 }
308 for sibling in jobs.iter().filter(|job| {
309 job.logical_job_id == failed.logical_job_id
310 && job.id != failed.id
311 && !job.state.is_terminal()
312 }) {
313 let mut active: workflow_job::ActiveModel = sibling.clone().into();
314 if sibling.state.is_active() {
315 let mut data = sibling.actions_context.clone().unwrap_or(json!({}));
316 data["cancel_requested"] = json!(true);
317 active.actions_context = Set(Some(data));
318 } else {
319 active.state = Set(JobState::Cancelled);
320 active.outcome = Set(Some("cancelled".into()));
321 active.finished_at = Set(Some(OffsetDateTime::now_utc()));
322 }
323 active.updated_at = Set(OffsetDateTime::now_utc());
324 active.update(self.db_write()).await?;
325 changed += 1;
326 }
327 }
328 Ok(changed)
329 }
330
331 async fn initialize_actions_metadata(&self, run: &workflow_run::Model) -> CiResult<()> {
332 if run.definition["_actions_initialized"]
333 .as_bool()
334 .unwrap_or(false)
335 {
336 return Ok(());
337 }
338 let workflow = ci_protocol::actions::from_snapshot(&run.definition)
339 .ok_or_else(|| CiError::Internal("Actions snapshot missing".into()))?;
340 let key = workflow
341 .projection()
342 .jobs
343 .keys()
344 .next()
345 .cloned()
346 .ok_or_else(|| CiError::InvalidArgument("workflow has no jobs".into()))?;
347 let context = json!({"workflow":workflow,"job":key,"inputs":run.event_payload.get("_gitdata_inputs").or_else(||run.event_payload.get("inputs")).cloned().unwrap_or(json!({}))});
348 let mut request = self
349 .actions_request(run, &context, &BTreeMap::new(), "evaluate")
350 .await?;
351 request["value"] = json!({"concurrency":workflow.document["concurrency"],"run-name":workflow.document["run-name"]});
352 let evaluated = self.actions_engine(request).await?;
353 let concurrency = &evaluated["concurrency"];
354 let group = concurrency
355 .as_str()
356 .or_else(|| concurrency["group"].as_str())
357 .filter(|group| !group.is_empty())
358 .map(str::to_lowercase);
359 let mut definition = run.definition.clone();
360 definition["_actions_initialized"] = json!(true);
361 if let Some(name) = evaluated["run-name"].as_str() {
362 definition["_display_name"] = json!(name);
363 }
364 if group.is_some() {
365 definition["concurrency"] = json!({"group":group,"cancel_in_progress":concurrency["cancel-in-progress"].as_bool().unwrap_or(false)});
366 }
367 let mut active: workflow_run::ActiveModel = run.clone().into();
368 active.definition = Set(definition);
369 active.concurrency_group = Set(group);
370 active.update(self.db_write()).await?;
371 Ok(())
372 }
373
374 pub async fn enforce_actions_concurrency(&self) -> CiResult<u64> {
375 use models::ci::workflow_run::WorkflowRunState as RunState;
376 let runs = workflow_run::Entity::find()
377 .filter(workflow_run::Column::State.is_in([
378 RunState::Queued,
379 RunState::Running,
380 RunState::CancelRequested,
381 ]))
382 .all(self.db_write())
383 .await?;
384 let mut groups = BTreeMap::<(Uuid, String), Vec<workflow_run::Model>>::new();
385 let mut changed = 0;
386 for run in runs {
387 if ci_protocol::actions::from_snapshot(&run.definition).is_some()
388 && let Some(group) = &run.concurrency_group
389 {
390 groups
391 .entry((run.repo_id, group.to_lowercase()))
392 .or_default()
393 .push(run);
394 }
395 }
396 for (_, mut runs) in groups {
397 runs.sort_by_key(|run| (run.created_at, run.id));
398 let pending: Vec<_> = runs
399 .iter()
400 .filter(|run| run.state == RunState::Queued)
401 .collect();
402 if let Some(latest) = pending.last() {
403 for old in pending.iter().take(pending.len().saturating_sub(1)) {
404 self.mark_run_cancel_requested(old.id).await?;
405 changed += 1;
406 }
407 if latest.definition["concurrency"]["cancel_in_progress"]
408 .as_bool()
409 .unwrap_or(false)
410 {
411 for active in runs.iter().filter(|run| run.state == RunState::Running) {
412 self.mark_run_cancel_requested(active.id).await?;
413 changed += 1;
414 }
415 }
416 }
417 }
418 let jobs = workflow_job::Entity::find()
419 .filter(workflow_job::Column::IsTemplate.eq(false))
420 .filter(workflow_job::Column::State.is_in([
421 JobState::Queued,
422 JobState::Claimed,
423 JobState::Running,
424 ]))
425 .all(self.db_write())
426 .await?;
427 let mut groups = BTreeMap::<(Uuid, String), Vec<workflow_job::Model>>::new();
428 for job in jobs {
429 if let Some(concurrency) = job
430 .actions_context
431 .as_ref()
432 .map(|ctx| &ctx["instance"]["concurrency"])
433 && let Some(group) = concurrency
434 .as_str()
435 .or_else(|| concurrency["group"].as_str())
436 {
437 groups
438 .entry((job.repo_id, group.to_lowercase()))
439 .or_default()
440 .push(job);
441 }
442 }
443 for (_, mut jobs) in groups {
444 jobs.sort_by_key(|job| (job.created_at, job.id));
445 let pending: Vec<_> = jobs
446 .iter()
447 .filter(|job| job.state == JobState::Queued)
448 .collect();
449 if let Some(latest) = pending.last() {
450 for old in pending.iter().take(pending.len().saturating_sub(1)) {
451 let mut active: workflow_job::ActiveModel = (*old).clone().into();
452 active.state = Set(JobState::Cancelled);
453 active.outcome = Set(Some("cancelled".into()));
454 active.finished_at = Set(Some(OffsetDateTime::now_utc()));
455 active.update(self.db_write()).await?;
456 changed += 1;
457 }
458 if latest.actions_context.as_ref().is_some_and(|ctx| {
459 ctx["instance"]["concurrency"]["cancel-in-progress"]
460 .as_bool()
461 .unwrap_or(false)
462 }) {
463 for old in jobs.iter().filter(|job| job.state.is_active()) {
464 let mut context = old.actions_context.clone().unwrap_or(json!({}));
465 context["cancel_requested"] = json!(true);
466 let mut active: workflow_job::ActiveModel = old.clone().into();
467 active.actions_context = Set(Some(context));
468 active.update(self.db_write()).await?;
469 changed += 1;
470 }
471 }
472 }
473 }
474 Ok(changed)
475 }
476
477 async fn expand_actions_template(
478 &self,
479 run: &workflow_run::Model,
480 template: &workflow_job::Model,
481 jobs: &[workflow_job::Model],
482 ) -> CiResult<u64> {
483 let context = template
484 .actions_context
485 .clone()
486 .ok_or_else(|| CiError::Internal("Actions context missing".into()))?;
487 let needs = Self::actions_needs(template, jobs);
488 let request = self
489 .actions_request(run, &context, &needs, "expand")
490 .await?;
491 let expansion: EngineExpansion =
492 serde_json::from_value(self.actions_engine(request).await?)?;
493 let workflow: ActionsWorkflow = serde_json::from_value(context["workflow"].clone())?;
494 let mut resolved = Vec::new();
495 for instance in expansion.instances {
496 let call = if instance.uses.is_empty() {
497 None
498 } else {
499 Some(
500 self.resolve_actions_call(run, &workflow, &instance.uses)
501 .await?,
502 )
503 };
504 resolved.push((instance, call));
505 }
506 let txn = self.db_write().begin().await?;
507 let current = workflow_job::Entity::find_by_id(template.id)
508 .lock_exclusive()
509 .one(&txn)
510 .await?
511 .ok_or(CiError::NotFound)?;
512 if current.state != JobState::Pending {
513 return Ok(0);
514 }
515 let now = OffsetDateTime::now_utc();
516 let mut active: workflow_job::ActiveModel = current.into();
517 if expansion.skipped || resolved.is_empty() {
518 active.state = Set(JobState::Skipped);
519 active.finished_at = Set(Some(now));
520 active.outcome = Set(Some("skipped".into()));
521 } else {
522 active.state = Set(JobState::Running);
523 active.started_at = Set(Some(now));
524 }
525 let mut updated_context = context.clone();
526 updated_context["expanded"] = json!(true);
527 active.actions_context = Set(Some(updated_context));
528 active.updated_at = Set(now);
529 active.update(&txn).await?;
530 for (instance, call) in resolved {
531 let id = Uuid::now_v7();
532 let key = format!("{}[{}]", template.job_key, instance.index);
533 let mut child_context = context.clone();
534 child_context["instance"] = serde_json::to_value(&instance)?;
535 child_context["needs_results"] = serde_json::to_value(&needs)?;
536 child_context["permissions"] = effective_permissions(
537 &workflow.document,
538 context.get("permissions"),
539 &instance.permissions,
540 );
541 child_context["kind"] = json!(if call.is_some() {
542 "call_instance"
543 } else {
544 "instance"
545 });
546 if let Some((called, sha)) = &call {
547 child_context["called_workflow"] = serde_json::to_value(called)?;
548 child_context["resolved_sha"] = json!(sha);
549 }
550 workflow_job::ActiveModel {
551 id: Set(id),
552 run_id: Set(run.id),
553 repo_id: Set(template.repo_id),
554 space_id: Set(template.space_id),
555 name: Set(instance.name.clone()),
556 job_key: Set(key.clone()),
557 definition: Set(template.definition.clone()),
558 actions_context: Set(Some(child_context.clone())),
559 outputs: Set(None),
560 outcome: Set(None),
561 logical_job_id: Set(Some(template.id)),
562 is_template: Set(call.is_some()),
563 actions_id: Set(Some(crate::actions::identity(&txn, id, "job").await?)),
564 runs_on: Set(json!(instance.runs_on)),
565 environment: Set(
566 (!instance.environment.is_empty()).then_some(instance.environment.clone())
567 ),
568 needs: Set(template.needs.clone()),
569 state: Set(if call.is_some() {
570 JobState::Running
571 } else {
572 JobState::Queued
573 }),
574 runner_id: Set(None),
575 lease_token: Set(None),
576 lease_expires_at: Set(None),
577 attempt: Set(0),
578 exit_code: Set(None),
579 error: Set(None),
580 started_at: Set(call.as_ref().map(|_| now)),
581 finished_at: Set(None),
582 created_at: Set(now),
583 updated_at: Set(now),
584 }
585 .insert(&txn)
586 .await?;
587 if let Some((called, _)) = call {
588 if called.triggers().get("workflow_call").is_none() {
589 return Err(CiError::InvalidArgument(format!(
590 "{} does not declare workflow_call",
591 called.file
592 )));
593 }
594 let inputs = validate_call_inputs(&called, instance.inputs.clone())?;
595 let scope = format!("{key}/");
596 let projection = called.projection();
597 for (job_key, job) in projection.jobs {
598 let mut next_context = json!({"workflow":called,"job":job_key,"scope":scope,"inputs":inputs,"kind":"template","expanded":false,"permissions":child_context["permissions"],"caller_context":context,"secret_mapping":workflow.document["jobs"][context["job"].as_str().unwrap_or_default()]["secrets"]});
599 next_context["caller_needs"] = serde_json::to_value(&needs)?;
600 workflow_job::ActiveModel {
601 id: Set(Uuid::now_v7()),
602 run_id: Set(run.id),
603 repo_id: Set(template.repo_id),
604 space_id: Set(template.space_id),
605 name: Set(job_key.clone()),
606 job_key: Set(format!("{scope}{job_key}")),
607 definition: Set(serde_json::to_value(&job)?),
608 actions_context: Set(Some(next_context)),
609 is_template: Set(true),
610 logical_job_id: Set(Some(id)),
611 runs_on: Set(json!(job.runs_on)),
612 environment: Set(None),
613 needs: Set(json!(
614 job.needs
615 .iter()
616 .map(|name| format!("{scope}{name}"))
617 .collect::<Vec<_>>()
618 )),
619 state: Set(JobState::Pending),
620 attempt: Set(0),
621 created_at: Set(now),
622 updated_at: Set(now),
623 ..Default::default()
624 }
625 .insert(&txn)
626 .await?;
627 }
628 }
629 }
630 txn.commit().await?;
631 Ok(1)
632 }
633
634 async fn aggregate_actions_template(
635 &self,
636 run: &workflow_run::Model,
637 template: &workflow_job::Model,
638 jobs: &[workflow_job::Model],
639 ) -> CiResult<bool> {
640 let children: Vec<_> = jobs
641 .iter()
642 .filter(|job| job.logical_job_id == Some(template.id))
643 .collect();
644 if children.is_empty() || children.iter().any(|job| !job.state.is_terminal()) {
645 return Ok(false);
646 }
647 let failed = children.iter().any(|job| actions_result(job) == "failure");
648 let cancelled = children
649 .iter()
650 .all(|job| actions_result(job) == "cancelled");
651 let state = if failed {
652 JobState::Failure
653 } else if cancelled {
654 JobState::Cancelled
655 } else {
656 JobState::Success
657 };
658 let mut outputs = BTreeMap::<String, String>::new();
659 let context = template.actions_context.as_ref().unwrap_or(&Value::Null);
660 if context["kind"] == "call_instance" {
661 let called: ActionsWorkflow =
662 serde_json::from_value(context["called_workflow"].clone())?;
663 let mut eval_context = context.clone();
664 eval_context["workflow"] = serde_json::to_value(&called)?;
665 eval_context["job"] = json!(
666 called
667 .projection()
668 .jobs
669 .keys()
670 .next()
671 .cloned()
672 .unwrap_or_default()
673 );
674 let mut request = self
675 .actions_request(run, &eval_context, &BTreeMap::new(), "evaluate")
676 .await?;
677 request["jobs"]=json!(children.iter().filter_map(|job|job.actions_context.as_ref().and_then(|ctx|ctx["job"].as_str()).map(|key|(key.to_owned(),json!({"outputs":job.outputs.clone().unwrap_or(json!({})),"result":actions_result(job)})))).collect::<BTreeMap<_,_>>());
678 request["value"] = called.document["on"]["workflow_call"]["outputs"].clone();
679 let evaluated = self.actions_engine(request).await?;
680 for (key, value) in evaluated.as_object().into_iter().flatten() {
681 if let Some(value) = value["value"].as_str() {
682 outputs.insert(key.clone(), value.into());
683 }
684 }
685 } else {
686 let mut ordered = children;
687 ordered.sort_by_key(|job| (job.finished_at, job.id));
688 for child in ordered.iter().filter(|job| job.state == JobState::Success) {
689 let values: BTreeMap<String, String> = child
690 .outputs
691 .as_ref()
692 .and_then(|v| serde_json::from_value(v.clone()).ok())
693 .unwrap_or_default();
694 for (key, value) in values {
695 if !value.is_empty() {
696 outputs.insert(key, value);
697 }
698 }
699 }
700 }
701 let mut active: workflow_job::ActiveModel = template.clone().into();
702 active.state = Set(state);
703 active.outcome = Set(Some(state.as_str().into()));
704 active.outputs = Set(Some(json!(outputs)));
705 active.finished_at = Set(Some(OffsetDateTime::now_utc()));
706 active.updated_at = Set(OffsetDateTime::now_utc());
707 active.update(self.db_write()).await?;
708 Ok(true)
709 }
710
711 async fn resolve_actions_call(
712 &self,
713 run: &workflow_run::Model,
714 _caller: &ActionsWorkflow,
715 reference: &str,
716 ) -> CiResult<(ActionsWorkflow, String)> {
717 if reference.starts_with("./") {
718 let repo = models::repo::repos::Entity::find_by_id(run.repo_id)
719 .one(self.db_read())
720 .await?
721 .ok_or(CiError::NotFound)?;
722 let space = self.space_of_repo(&repo).await?;
723 let loaded = self
724 .load_workflow_by_file(
725 &space,
726 &repo.name,
727 &run.commit_sha,
728 reference.trim_start_matches("./"),
729 )
730 .await?;
731 return Ok((
732 loaded.actions.ok_or_else(|| {
733 CiError::InvalidArgument("called workflow has no Actions definition".into())
734 })?,
735 run.commit_sha.clone(),
736 ));
737 }
738 let (path, revision) = reference.rsplit_once('@').ok_or_else(|| {
739 CiError::InvalidArgument("reusable workflow reference must include @ref".into())
740 })?;
741 let parts: Vec<_> = path.split('/').collect();
742 if parts.len() != 5
743 || parts[2] != ".github"
744 || parts[3] != "workflows"
745 || parts.iter().any(|part| part.is_empty() || *part == "..")
746 {
747 return Err(CiError::InvalidArgument(
748 "expected owner/repo/.github/workflows/file@ref".into(),
749 ));
750 }
751 let remote = format!("https://github.com/{}/{}.git", parts[0], parts[1]);
752 let output = tokio::time::timeout(
753 std::time::Duration::from_secs(30),
754 tokio::process::Command::new("git")
755 .arg("ls-remote")
756 .arg(&remote)
757 .arg(revision)
758 .arg(format!("refs/heads/{revision}"))
759 .arg(format!("refs/tags/{revision}"))
760 .arg(format!("refs/tags/{revision}^{{}}"))
761 .output(),
762 )
763 .await
764 .map_err(|_| CiError::InvalidArgument("reusable workflow resolution timed out".into()))?
765 .map_err(|error| CiError::Internal(error.to_string()))?;
766 let text = String::from_utf8_lossy(&output.stdout);
767 let sha = if revision.len() == 40 && revision.chars().all(|ch| ch.is_ascii_hexdigit()) {
768 revision.to_owned()
769 } else {
770 text.lines()
771 .last()
772 .and_then(|line| line.split_whitespace().next())
773 .ok_or_else(|| {
774 CiError::InvalidArgument(format!("workflow ref `{reference}` was not found"))
775 })?
776 .to_owned()
777 };
778 let url = format!(
779 "https://raw.githubusercontent.com/{}/{}/{}/.github/workflows/{}",
780 parts[0], parts[1], sha, parts[4]
781 );
782 let source = reqwest::Client::builder()
783 .timeout(std::time::Duration::from_secs(30))
784 .build()
785 .map_err(|error| CiError::Internal(error.to_string()))?
786 .get(url)
787 .send()
788 .await
789 .map_err(|error| CiError::InvalidArgument(error.to_string()))?
790 .error_for_status()
791 .map_err(|error| CiError::InvalidArgument(error.to_string()))?
792 .text()
793 .await
794 .map_err(|error| CiError::InvalidArgument(error.to_string()))?;
795 if source.len() > 256 * 1024 {
796 return Err(CiError::InvalidArgument(
797 "called workflow exceeds size limit".into(),
798 ));
799 }
800 Ok((
801 self.parse_actions_workflow(&format!(".github/workflows/{}", parts[4]), &source)
802 .await?,
803 sha,
804 ))
805 }
806}
807
808pub fn actions_result(job: &workflow_job::Model) -> &str {
809 if job.actions_context.as_ref().is_some_and(|ctx| {
810 ctx["instance"]["continue_on_error"]
811 .as_bool()
812 .unwrap_or(false)
813 }) && job.state == JobState::Failure
814 {
815 return "success";
816 }
817 match job.state {
818 JobState::Success => "success",
819 JobState::Skipped => "skipped",
820 JobState::Cancelled => "cancelled",
821 JobState::Failure | JobState::TimedOut => "failure",
822 _ => "pending",
823 }
824}
825
826fn permission_map(value: &Value) -> Value {
827 match value.as_str() {
828 Some("read-all") => json!({"contents":"read","id-token":"none"}),
829 Some("write-all") => json!({"contents":"write","id-token":"write"}),
830 _ => value.clone(),
831 }
832}
833
834fn effective_permissions(workflow: &Value, ceiling: Option<&Value>, job: &Value) -> Value {
835 let mut value = permission_map(if !job.is_null() {
836 job
837 } else {
838 workflow.get("permissions").unwrap_or(&Value::Null)
839 });
840 if value.is_null() {
841 value = json!({"contents":"read"})
842 }
843 if let Some(ceiling) = ceiling.filter(|v| !v.is_null()) {
844 for name in ["contents", "id-token"] {
845 let rank = |v: Option<&str>| match v {
846 Some("write") => 2,
847 Some("read") => 1,
848 _ => 0,
849 };
850 if rank(value[name].as_str()) > rank(ceiling[name].as_str()) {
851 value[name] = ceiling[name].clone();
852 }
853 }
854 }
855 value
856}
857
858fn validate_call_inputs(workflow: &ActionsWorkflow, provided: Value) -> CiResult<Value> {
859 let mut values = provided.as_object().cloned().unwrap_or_default();
860 let definitions = workflow.document["on"]["workflow_call"]["inputs"]
861 .as_object()
862 .cloned()
863 .unwrap_or_default();
864 for name in values.keys() {
865 if !definitions.contains_key(name) {
866 return Err(CiError::InvalidArgument(format!(
867 "unknown workflow_call input `{name}`"
868 )));
869 }
870 }
871 for (name, definition) in definitions {
872 if !values.contains_key(&name) {
873 if definition["required"].as_bool().unwrap_or(false)
874 && definition.get("default").is_none()
875 {
876 return Err(CiError::InvalidArgument(format!(
877 "required workflow_call input `{name}` is missing"
878 )));
879 }
880 values.insert(
881 name.clone(),
882 definition.get("default").cloned().unwrap_or_else(|| {
883 match definition["type"].as_str() {
884 Some("boolean") => json!(false),
885 Some("number") => json!(0),
886 _ => json!(""),
887 }
888 }),
889 );
890 }
891 let value = &values[&name];
892 let valid = match definition["type"].as_str() {
893 Some("boolean") => value.is_boolean(),
894 Some("number") => value.is_number(),
895 _ => value.is_string(),
896 };
897 if !valid {
898 return Err(CiError::InvalidArgument(format!(
899 "workflow_call input `{name}` has the wrong type"
900 )));
901 }
902 }
903 Ok(Value::Object(values))
904}
Deleted lib/ci/src/environments.rs
@@ -1,462 +0,0 @@
1use models::ci::ci_deployment::{self, DeploymentState};
2use models::ci::ci_environment;
3use models::ci::ci_secret::{self, CiSecretScope};
4use models::space::spaces;
5use sea_orm::sea_query::Expr;
6use sea_orm::{
7 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
8};
9use time::OffsetDateTime;
10use uuid::Uuid;
11
12use crate::errors::{CiError, CiResult};
13use crate::secrets::{decrypt, encrypt};
14use crate::service::CiService;
15
16#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
17pub struct SecretDto {
18 pub id: Uuid,
19 pub name: String,
20 pub scope: String,
21 pub repo_id: Option<Uuid>,
22 pub space_id: Option<Uuid>,
23 pub environment_id: Option<Uuid>,
24 pub created_at: OffsetDateTime,
25 pub updated_at: OffsetDateTime,
26}
27
28#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
29pub struct EnvironmentDto {
30 pub id: Uuid,
31 pub space_id: Uuid,
32 pub name: String,
33 pub description: Option<String>,
34 pub required_labels: Vec<String>,
35 pub created_at: OffsetDateTime,
36 pub updated_at: OffsetDateTime,
37}
38
39#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
40pub struct DeploymentDto {
41 pub id: Uuid,
42 pub environment_id: Uuid,
43 pub environment_name: String,
44 pub run_id: Uuid,
45 pub job_id: Uuid,
46 pub repo_id: Uuid,
47 pub commit_sha: String,
48 pub git_ref: String,
49 pub state: String,
50 pub actor_id: Option<i64>,
51 pub created_at: OffsetDateTime,
52 pub updated_at: OffsetDateTime,
53}
54
55impl From<ci_secret::Model> for SecretDto {
56 fn from(m: ci_secret::Model) -> Self {
57 Self {
58 id: m.id,
59 name: m.name,
60 scope: match m.scope {
61 CiSecretScope::Repo => "repo",
62 CiSecretScope::Space => "space",
63 CiSecretScope::Environment => "environment",
64 }
65 .to_string(),
66 repo_id: m.repo_id,
67 space_id: m.space_id,
68 environment_id: m.environment_id,
69 created_at: m.created_at,
70 updated_at: m.updated_at,
71 }
72 }
73}
74
75impl From<ci_environment::Model> for EnvironmentDto {
76 fn from(m: ci_environment::Model) -> Self {
77 Self {
78 id: m.id,
79 space_id: m.space_id,
80 name: m.name,
81 description: m.description,
82 required_labels: serde_json::from_value(m.required_labels).unwrap_or_default(),
83 created_at: m.created_at,
84 updated_at: m.updated_at,
85 }
86 }
87}
88
89impl CiService {
90 pub async fn list_repo_secrets(&self, repo_id: Uuid) -> CiResult<Vec<SecretDto>> {
91 let rows = ci_secret::Entity::find()
92 .filter(ci_secret::Column::RepoId.eq(repo_id))
93 .filter(ci_secret::Column::Scope.eq(CiSecretScope::Repo))
94 .order_by_asc(ci_secret::Column::Name)
95 .all(self.db_read())
96 .await?;
97 Ok(rows.into_iter().map(SecretDto::from).collect())
98 }
99
100 pub async fn list_space_secrets(&self, space_id: Uuid) -> CiResult<Vec<SecretDto>> {
101 let rows = ci_secret::Entity::find()
102 .filter(ci_secret::Column::SpaceId.eq(space_id))
103 .filter(ci_secret::Column::Scope.eq(CiSecretScope::Space))
104 .order_by_asc(ci_secret::Column::Name)
105 .all(self.db_read())
106 .await?;
107 Ok(rows.into_iter().map(SecretDto::from).collect())
108 }
109
110 pub async fn list_environment_secrets(&self, environment_id: Uuid) -> CiResult<Vec<SecretDto>> {
111 let rows = ci_secret::Entity::find()
112 .filter(ci_secret::Column::EnvironmentId.eq(environment_id))
113 .filter(ci_secret::Column::Scope.eq(CiSecretScope::Environment))
114 .order_by_asc(ci_secret::Column::Name)
115 .all(self.db_read())
116 .await?;
117 Ok(rows.into_iter().map(SecretDto::from).collect())
118 }
119
120 pub async fn upsert_repo_secret(
121 &self,
122 repo_id: Uuid,
123 name: &str,
124 value: &str,
125 actor: Option<i64>,
126 ) -> CiResult<SecretDto> {
127 self.upsert_secret(SecretScopeTarget::Repo(repo_id), name, value, actor)
128 .await
129 }
130
131 pub async fn upsert_space_secret(
132 &self,
133 space_id: Uuid,
134 name: &str,
135 value: &str,
136 actor: Option<i64>,
137 ) -> CiResult<SecretDto> {
138 self.upsert_secret(SecretScopeTarget::Space(space_id), name, value, actor)
139 .await
140 }
141
142 pub async fn upsert_environment_secret(
143 &self,
144 environment_id: Uuid,
145 name: &str,
146 value: &str,
147 actor: Option<i64>,
148 ) -> CiResult<SecretDto> {
149 self.upsert_secret(
150 SecretScopeTarget::Environment(environment_id),
151 name,
152 value,
153 actor,
154 )
155 .await
156 }
157
158 async fn upsert_secret(
159 &self,
160 target: SecretScopeTarget,
161 name: &str,
162 value: &str,
163 actor: Option<i64>,
164 ) -> CiResult<SecretDto> {
165 let name = validate_secret_name(name)?;
166 if value.is_empty() {
167 return Err(CiError::InvalidArgument(
168 "secret value must not be empty".to_string(),
169 ));
170 }
171 if value.len() > 64 * 1024 {
172 return Err(CiError::InvalidArgument(
173 "secret value exceeds the 64KiB limit".to_string(),
174 ));
175 }
176 let ciphertext = encrypt(self.encryption_key(), value)?;
177 let now = OffsetDateTime::now_utc();
178 let existing = match &target {
179 SecretScopeTarget::Repo(repo_id) => {
180 ci_secret::Entity::find()
181 .filter(ci_secret::Column::RepoId.eq(*repo_id))
182 .filter(ci_secret::Column::Name.eq(name.clone()))
183 .one(self.db_read())
184 .await?
185 }
186 SecretScopeTarget::Space(space_id) => {
187 ci_secret::Entity::find()
188 .filter(ci_secret::Column::SpaceId.eq(*space_id))
189 .filter(ci_secret::Column::Name.eq(name.clone()))
190 .one(self.db_read())
191 .await?
192 }
193 SecretScopeTarget::Environment(environment_id) => {
194 ci_secret::Entity::find()
195 .filter(ci_secret::Column::EnvironmentId.eq(*environment_id))
196 .filter(ci_secret::Column::Name.eq(name.clone()))
197 .one(self.db_read())
198 .await?
199 }
200 };
201 let model = match existing {
202 Some(row) => {
203 let mut am: ci_secret::ActiveModel = row.into();
204 am.value_ciphertext = Set(ciphertext);
205 am.updated_at = Set(now);
206 am.update(self.db_write()).await?
207 }
208 None => {
209 let (scope, repo_id, space_id, environment_id) = match &target {
210 SecretScopeTarget::Repo(id) => (
211 CiSecretScope::Repo,
212 Some(*id),
213 Some(self.repo_space(*id).await?),
214 None,
215 ),
216 SecretScopeTarget::Space(id) => (CiSecretScope::Space, None, Some(*id), None),
217 SecretScopeTarget::Environment(id) => {
218 let row = ci_environment::Entity::find_by_id(*id)
219 .one(self.db_read())
220 .await?
221 .ok_or(CiError::NotFound)?;
222 (
223 CiSecretScope::Environment,
224 None,
225 Some(row.space_id),
226 Some(*id),
227 )
228 }
229 };
230 ci_secret::ActiveModel {
231 id: Set(Uuid::now_v7()),
232 scope: Set(scope),
233 repo_id: Set(repo_id),
234 space_id: Set(space_id),
235 environment_id: Set(environment_id),
236 name: Set(name.clone()),
237 value_ciphertext: Set(ciphertext),
238 created_by: Set(actor),
239 created_at: Set(now),
240 updated_at: Set(now),
241 }
242 .insert(self.db_write())
243 .await?
244 }
245 };
246 Ok(SecretDto::from(model))
247 }
248
249 async fn repo_space(&self, repo_id: Uuid) -> CiResult<Uuid> {
250 let repo = self.repo_by_id(repo_id).await?;
251 Ok(self.space_of_repo(&repo).await?.id)
252 }
253
254 pub async fn delete_secret(&self, id: Uuid) -> CiResult<()> {
255 let updated = ci_secret::Entity::delete_by_id(id)
256 .exec(self.db_write())
257 .await?;
258 if updated.rows_affected == 0 {
259 return Err(CiError::NotFound);
260 }
261 Ok(())
262 }
263
264 /// Merges environment overriding repo overriding space secrets; later
265 /// entries win when names collide.
266 pub async fn resolved_secrets(
267 &self,
268 repo_id: Uuid,
269 space_id: Uuid,
270 environment: Option<&str>,
271 ) -> CiResult<Vec<(String, String)>> {
272 let mut merged: std::collections::BTreeMap<String, String> =
273 std::collections::BTreeMap::new();
274 let space_rows = ci_secret::Entity::find()
275 .filter(ci_secret::Column::SpaceId.eq(space_id))
276 .filter(ci_secret::Column::Scope.eq(CiSecretScope::Space))
277 .all(self.db_read())
278 .await?;
279 for row in space_rows {
280 let value = decrypt(self.encryption_key(), &row.value_ciphertext)?;
281 merged.insert(row.name, value);
282 }
283 let repo_rows = ci_secret::Entity::find()
284 .filter(ci_secret::Column::RepoId.eq(repo_id))
285 .filter(ci_secret::Column::Scope.eq(CiSecretScope::Repo))
286 .all(self.db_read())
287 .await?;
288 for row in repo_rows {
289 let value = decrypt(self.encryption_key(), &row.value_ciphertext)?;
290 merged.insert(row.name, value);
291 }
292 if let Some(environment) = environment {
293 let env_row = self
294 .find_actions_environment(space_id, repo_id, environment)
295 .await?;
296 if let Some(env_row) = env_row {
297 let env_secrets = ci_secret::Entity::find()
298 .filter(ci_secret::Column::EnvironmentId.eq(env_row.id))
299 .filter(ci_secret::Column::Scope.eq(CiSecretScope::Environment))
300 .all(self.db_read())
301 .await?;
302 for row in env_secrets {
303 let value = decrypt(self.encryption_key(), &row.value_ciphertext)?;
304 merged.insert(row.name, value);
305 }
306 }
307 }
308 Ok(merged.into_iter().collect())
309 }
310}
311
312enum SecretScopeTarget {
313 Repo(Uuid),
314 Space(Uuid),
315 Environment(Uuid),
316}
317
318fn validate_secret_name(name: &str) -> CiResult<String> {
319 let trimmed = name.trim();
320 if trimmed.is_empty() || trimmed.len() > 128 {
321 return Err(CiError::InvalidArgument(
322 "secret name must be 1-128 characters".to_string(),
323 ));
324 }
325 let valid = trimmed
326 .chars()
327 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
328 if !valid {
329 return Err(CiError::InvalidArgument(
330 "secret name may only contain alphanumerics, `_` and `-`".to_string(),
331 ));
332 }
333 Ok(trimmed.to_string())
334}
335
336impl CiService {
337 pub async fn list_environments(&self, space: &spaces::Model) -> CiResult<Vec<EnvironmentDto>> {
338 let rows = ci_environment::Entity::find()
339 .filter(ci_environment::Column::SpaceId.eq(space.id))
340 .order_by_asc(ci_environment::Column::Name)
341 .all(self.db_read())
342 .await?;
343 Ok(rows.into_iter().map(EnvironmentDto::from).collect())
344 }
345
346 pub async fn upsert_environment(
347 &self,
348 space_id: Uuid,
349 name: &str,
350 description: Option<String>,
351 required_labels: Vec<String>,
352 ) -> CiResult<EnvironmentDto> {
353 let name = name.trim();
354 if name.is_empty() || name.len() > 128 || name.contains('/') || name.contains('\\') {
355 return Err(CiError::InvalidArgument(
356 "environment name must be 1-128 characters without slashes".to_string(),
357 ));
358 }
359 if required_labels.iter().any(|l| l.trim().is_empty()) {
360 return Err(CiError::InvalidArgument(
361 "environment labels must not be empty".to_string(),
362 ));
363 }
364 let now = OffsetDateTime::now_utc();
365 let existing = ci_environment::Entity::find()
366 .filter(ci_environment::Column::SpaceId.eq(space_id))
367 .filter(ci_environment::Column::Name.eq(name.to_string()))
368 .one(self.db_read())
369 .await?;
370 let model = match existing {
371 Some(row) => {
372 let mut am: ci_environment::ActiveModel = row.into();
373 am.description = Set(description);
374 am.required_labels = Set(serde_json::to_value(&required_labels)?);
375 am.updated_at = Set(now);
376 am.update(self.db_write()).await?
377 }
378 None => {
379 ci_environment::ActiveModel {
380 repo_id: Set(None),
381 rules: Set(None),
382 id: Set(Uuid::now_v7()),
383 space_id: Set(space_id),
384 name: Set(name.to_string()),
385 description: Set(description),
386 required_labels: Set(serde_json::to_value(&required_labels)?),
387 created_at: Set(now),
388 updated_at: Set(now),
389 }
390 .insert(self.db_write())
391 .await?
392 }
393 };
394 Ok(EnvironmentDto::from(model))
395 }
396
397 pub async fn delete_environment(&self, id: Uuid) -> CiResult<()> {
398 let _ = ci_secret::Entity::delete_many()
399 .filter(ci_secret::Column::EnvironmentId.eq(id))
400 .exec(self.db_write())
401 .await;
402 let _ = ci_deployment::Entity::delete_many()
403 .filter(ci_deployment::Column::EnvironmentId.eq(id))
404 .exec(self.db_write())
405 .await;
406 let updated = ci_environment::Entity::delete_by_id(id)
407 .exec(self.db_write())
408 .await?;
409 if updated.rows_affected == 0 {
410 return Err(CiError::NotFound);
411 }
412 Ok(())
413 }
414
415 pub async fn list_deployments(
416 &self,
417 environment_id: Uuid,
418 limit: u64,
419 ) -> CiResult<Vec<DeploymentDto>> {
420 let names: std::collections::HashMap<Uuid, String> = ci_environment::Entity::find()
421 .all(self.db_read())
422 .await?
423 .into_iter()
424 .map(|row| (row.id, row.name))
425 .collect();
426 let rows = ci_deployment::Entity::find()
427 .filter(ci_deployment::Column::EnvironmentId.eq(environment_id))
428 .order_by_desc(ci_deployment::Column::CreatedAt)
429 .limit(limit.clamp(1, 100))
430 .all(self.db_read())
431 .await?;
432 Ok(rows
433 .into_iter()
434 .map(|row| DeploymentDto {
435 id: row.id,
436 environment_id: row.environment_id,
437 environment_name: names.get(&row.environment_id).cloned().unwrap_or_default(),
438 run_id: row.run_id,
439 job_id: row.job_id,
440 repo_id: row.repo_id,
441 commit_sha: row.commit_sha,
442 git_ref: row.git_ref,
443 state: match row.state {
444 DeploymentState::Pending => "pending",
445 DeploymentState::Running => "running",
446 DeploymentState::Success => "success",
447 DeploymentState::Failure => "failure",
448 DeploymentState::Cancelled => "cancelled",
449 }
450 .to_string(),
451 actor_id: row.actor_id,
452 created_at: row.created_at,
453 updated_at: row.updated_at,
454 })
455 .collect())
456 }
457
458 pub async fn touch_environment_deployments(&self) -> CiResult<()> {
459 let _ = Expr::value(0);
460 Ok(())
461 }
462}
Deleted lib/ci/src/errors.rs
@@ -1,170 +0,0 @@
1use std::fmt;
2
3use axum::Json;
4use axum::http::StatusCode;
5use axum::response::{IntoResponse, Response};
6use export::rest::{AppResponseData, NoData};
7use utoipa::IntoResponses;
8
9#[derive(Debug, thiserror::Error)]
10pub enum CiError {
11 #[error("invalid argument: {0}")]
12 InvalidArgument(String),
13 #[error("conflict: {0}")]
14 Conflict(String),
15 #[error("resource not found")]
16 NotFound,
17 #[error("unauthorized")]
18 Unauthorized,
19 #[error("forbidden")]
20 Forbidden,
21 #[error("git error: {0}")]
22 Git(#[from] git::share::errors::GitError),
23 #[error("storage error: {0}")]
24 Storage(#[from] storage::StorageError),
25 #[error("database error: {0}")]
26 Db(#[from] sea_orm::DbErr),
27 #[error("serialization error: {0}")]
28 Serde(#[from] serde_json::Error),
29 #[error("internal error: {0}")]
30 Internal(String),
31}
32
33pub type CiResult<T> = Result<T, CiError>;
34
35#[derive(Debug)]
36pub enum CiApiError {
37 BadRequest(String),
38 Unauthorized,
39 Forbidden,
40 NotFound,
41 Conflict(String),
42 Internal(String),
43}
44
45impl fmt::Display for CiApiError {
46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47 match self {
48 Self::BadRequest(message) => write!(f, "{message}"),
49 Self::Unauthorized => write!(f, "unauthorized"),
50 Self::Forbidden => write!(f, "forbidden"),
51 Self::NotFound => write!(f, "not found"),
52 Self::Conflict(message) => write!(f, "{message}"),
53 Self::Internal(message) => write!(f, "{message}"),
54 }
55 }
56}
57
58impl std::error::Error for CiApiError {}
59
60impl From<CiError> for CiApiError {
61 fn from(error: CiError) -> Self {
62 match error {
63 CiError::NotFound => Self::NotFound,
64 CiError::Unauthorized => Self::Unauthorized,
65 CiError::Forbidden => Self::Forbidden,
66 CiError::Conflict(msg) => Self::Conflict(msg),
67 CiError::InvalidArgument(msg) => Self::BadRequest(msg),
68 other => {
69 tracing::warn!(error = %other, "ci handler failed");
70 Self::Internal("internal server error".to_string())
71 }
72 }
73 }
74}
75
76impl From<sea_orm::DbErr> for CiApiError {
77 fn from(error: sea_orm::DbErr) -> Self {
78 Self::Internal(format!("database error: {error}"))
79 }
80}
81
82#[derive(Debug, IntoResponses)]
83pub enum ApiError {
84 #[response(status = 400, description = "request failed validation")]
85 BadRequest(#[to_schema] Box<AppResponseData<NoData>>),
86 #[response(status = 401, description = "authentication required")]
87 Unauthorized(#[to_schema] Box<AppResponseData<NoData>>),
88 #[response(status = 403, description = "operation not allowed")]
89 Forbidden(#[to_schema] Box<AppResponseData<NoData>>),
90 #[response(status = 404, description = "resource not found")]
91 NotFound(#[to_schema] Box<AppResponseData<NoData>>),
92 #[response(status = 409, description = "resource conflict")]
93 Conflict(#[to_schema] Box<AppResponseData<NoData>>),
94 #[response(status = 500, description = "internal server error")]
95 Internal(#[to_schema] Box<AppResponseData<NoData>>),
96}
97
98impl ApiError {
99 /// Boxes a plain envelope into the enum's payload. The box keeps
100 /// `Result<T, ApiError>` small: an inline envelope is 128 bytes, which every
101 /// handler return type would otherwise carry on the happy path too.
102 fn envelope(code: i32, message: impl Into<String>) -> Box<AppResponseData<NoData>> {
103 Box::new(AppResponseData::err(code, message))
104 }
105
106 pub fn bad_request(message: impl Into<String>) -> Self {
107 Self::BadRequest(Self::envelope(400, message))
108 }
109
110 pub fn unauthorized() -> Self {
111 Self::Unauthorized(Self::envelope(401, "unauthorized"))
112 }
113
114 pub fn forbidden() -> Self {
115 Self::Forbidden(Self::envelope(403, "forbidden"))
116 }
117
118 pub fn not_found() -> Self {
119 Self::NotFound(Self::envelope(404, "not found"))
120 }
121
122 pub fn conflict(message: impl Into<String>) -> Self {
123 Self::Conflict(Self::envelope(409, message))
124 }
125
126 pub fn internal() -> Self {
127 Self::Internal(Self::envelope(500, "internal server error"))
128 }
129
130 fn decompose(self) -> (u16, AppResponseData<NoData>) {
131 match self {
132 Self::BadRequest(envelope) => (400, *envelope),
133 Self::Unauthorized(envelope) => (401, *envelope),
134 Self::Forbidden(envelope) => (403, *envelope),
135 Self::NotFound(envelope) => (404, *envelope),
136 Self::Conflict(envelope) => (409, *envelope),
137 Self::Internal(envelope) => (500, *envelope),
138 }
139 }
140}
141
142impl From<CiError> for ApiError {
143 fn from(error: CiError) -> Self {
144 Self::from(CiApiError::from(error))
145 }
146}
147
148impl From<CiApiError> for ApiError {
149 fn from(error: CiApiError) -> Self {
150 match error {
151 CiApiError::BadRequest(message) => Self::bad_request(message),
152 CiApiError::Unauthorized => Self::unauthorized(),
153 CiApiError::Forbidden => Self::forbidden(),
154 CiApiError::NotFound => Self::not_found(),
155 CiApiError::Conflict(message) => Self::conflict(message),
156 CiApiError::Internal(message) => {
157 tracing::warn!(message, "ci internal error");
158 Self::internal()
159 }
160 }
161 }
162}
163
164impl IntoResponse for ApiError {
165 fn into_response(self) -> Response {
166 let (code, envelope) = self.decompose();
167 let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
168 (status, Json(envelope)).into_response()
169 }
170}
Deleted lib/ci/src/events.rs
@@ -1,35 +0,0 @@
1use ci_protocol::events::CiEvent;
2use export::AppRedis;
3use git::sync::queue::SyncQueue;
4
5use crate::errors::{CiError, CiResult};
6
7#[must_use]
8pub fn event_queue(redis: AppRedis) -> SyncQueue {
9 SyncQueue::new(redis)
10}
11
12pub async fn publish_event(redis: &AppRedis, topic: &str, event: &CiEvent) -> CiResult<()> {
13 let payload = serde_json::to_vec(event)?;
14 let queue = SyncQueue::new(redis.clone());
15 queue
16 .publish_raw(topic, None, &payload)
17 .await
18 .map_err(|e| CiError::Internal(format!("event publish failed: {e}")))?;
19 tracing::debug!(topic, bytes = payload.len(), "ci event published");
20 Ok(())
21}
22
23pub async fn publish_event_to_dlq(redis: &AppRedis, topic: &str, payload: &[u8]) -> CiResult<()> {
24 let queue = SyncQueue::new(redis.clone());
25 queue
26 .publish_raw(topic, None, payload)
27 .await
28 .map_err(|e| CiError::Internal(format!("event dlq publish failed: {e}")))?;
29 Ok(())
30}
31
32pub fn decode_event(payload: &[u8]) -> CiResult<CiEvent> {
33 serde_json::from_slice(payload)
34 .map_err(|e| CiError::Internal(format!("malformed ci event: {e}")))
35}
Deleted lib/ci/src/lib.rs
@@ -1,30 +0,0 @@
1pub mod actions;
2pub mod actions_environments;
3pub mod actions_events;
4mod actions_payload;
5pub mod actions_runtime;
6mod actions_scheduler;
7pub mod environments;
8pub mod errors;
9pub mod events;
10pub mod logs;
11pub mod net;
12pub mod outbound;
13pub mod runners;
14pub mod runs;
15pub mod scheduler;
16pub mod secrets;
17pub mod service;
18pub mod share;
19pub mod storage;
20pub mod triggers;
21pub mod workflows;
22
23pub use errors::{CiApiError, CiError};
24pub use service::CiService;
25pub use workflows::LoadedWorkflow;
26
27pub mod handlers;
28
29pub mod openapi;
30pub mod routes;
Deleted lib/ci/src/logs.rs
@@ -1,128 +0,0 @@
1use models::ci::ci_log_chunk;
2use models::ci::workflow_job;
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
5};
6use time::OffsetDateTime;
7use uuid::Uuid;
8
9use crate::errors::{CiError, CiResult};
10use crate::service::CiService;
11
12#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
13pub struct LogPageDto {
14 pub chunks: Vec<LogChunkDto>,
15 pub next_seq: i32,
16 pub complete: bool,
17}
18
19#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
20pub struct LogChunkDto {
21 pub seq: i32,
22 pub text: String,
23}
24
25impl CiService {
26 pub async fn append_log_chunks(
27 &self,
28 job_id: Uuid,
29 runner_id: Uuid,
30 chunks: &[ci_protocol::runner::LogChunk],
31 ) -> CiResult<()> {
32 self.ensure_job_owned(job_id, runner_id).await?;
33 let job = workflow_job::Entity::find_by_id(job_id)
34 .one(self.db_read())
35 .await?
36 .ok_or(CiError::NotFound)?;
37 let secrets = self.job_secret_values(job_id).await?;
38 let limit = self.config().ci.log_chunk_max_bytes;
39 for chunk in chunks {
40 let text = crate::secrets::redact(&chunk.text, &secrets);
41 if text.len() as u64 > limit {
42 return Err(CiError::InvalidArgument(
43 "log chunk exceeds the configured limit".to_string(),
44 ));
45 }
46 let existing = ci_log_chunk::Entity::find()
47 .filter(ci_log_chunk::Column::JobId.eq(job_id))
48 .filter(ci_log_chunk::Column::Seq.eq(chunk.seq))
49 .one(self.db_read())
50 .await?;
51 if existing.is_some() {
52 continue;
53 }
54 let key = Self::log_object_key(job.run_id, job_id, chunk.seq);
55 self.storage()
56 .client()
57 .put_with_content_type(&key, text.as_bytes(), "text/plain; charset=utf-8")
58 .await
59 .map_err(CiError::from)?;
60 let now = OffsetDateTime::now_utc();
61 ci_log_chunk::ActiveModel {
62 id: Set(Uuid::now_v7()),
63 job_id: Set(job_id),
64 seq: Set(chunk.seq),
65 object_key: Set(key),
66 size: Set(text.len() as i64),
67 created_at: Set(now),
68 }
69 .insert(self.db_write())
70 .await?;
71 }
72 Ok(())
73 }
74
75 pub async fn read_job_logs(
76 &self,
77 job_id: Uuid,
78 after_seq: i32,
79 limit: u64,
80 ) -> CiResult<LogPageDto> {
81 let limit = limit.clamp(1, 100);
82 let rows = ci_log_chunk::Entity::find()
83 .filter(ci_log_chunk::Column::JobId.eq(job_id))
84 .filter(ci_log_chunk::Column::Seq.gt(after_seq))
85 .order_by_asc(ci_log_chunk::Column::Seq)
86 .limit(limit)
87 .all(self.db_read())
88 .await?;
89 let mut chunks = Vec::with_capacity(rows.len());
90 let mut next_seq = after_seq;
91 for row in rows {
92 let data = self
93 .storage()
94 .client()
95 .get(&row.object_key)
96 .await
97 .map_err(CiError::from)?;
98 let text = String::from_utf8_lossy(&data).into_owned();
99 next_seq = row.seq;
100 chunks.push(LogChunkDto { seq: row.seq, text });
101 }
102 let job = workflow_job::Entity::find_by_id(job_id)
103 .one(self.db_read())
104 .await?
105 .ok_or(CiError::NotFound)?;
106 let complete = job.state.is_terminal();
107 Ok(LogPageDto {
108 chunks,
109 next_seq,
110 complete,
111 })
112 }
113
114 pub async fn job_secret_values(&self, job_id: Uuid) -> CiResult<Vec<String>> {
115 let job = workflow_job::Entity::find_by_id(job_id)
116 .one(self.db_read())
117 .await?
118 .ok_or(CiError::NotFound)?;
119 let mut out = Vec::new();
120 for secret in self
121 .resolved_secrets(job.repo_id, job.space_id, job.environment.as_deref())
122 .await?
123 {
124 out.push(secret.1);
125 }
126 Ok(out)
127 }
128}
Deleted lib/ci/src/net.rs
@@ -1,25 +0,0 @@
1use std::net::IpAddr;
2
3#[must_use]
4pub fn is_public_ip(ip: &IpAddr) -> bool {
5 match ip {
6 IpAddr::V4(v4) => {
7 !(v4.is_private()
8 || v4.is_loopback()
9 || v4.is_link_local()
10 || v4.is_broadcast()
11 || v4.is_documentation()
12 || v4.is_unspecified()
13 || v4.octets()[0] == 0
14 || (v4.octets()[0] == 100 && (64..128).contains(&v4.octets()[1]))
15 || v4.octets()[0] >= 224)
16 }
17 IpAddr::V6(v6) => {
18 !(v6.is_loopback()
19 || v6.is_unspecified()
20 || v6.is_unique_local()
21 || v6.is_unicast_link_local()
22 || v6.is_multicast())
23 }
24 }
25}
Deleted lib/ci/src/openapi.rs
@@ -1,73 +0,0 @@
1use utoipa::OpenApi;
2
3use export::rest::{AppResponseData, NoData};
4
5#[derive(OpenApi)]
6#[openapi(
7 info(title = "gitdata pipelines api", version = "1.0"),
8 paths(
9 crate::handlers::actions::dispatch,
10 crate::handlers::actions::environments,
11 crate::handlers::actions::upsert_environment,
12 crate::handlers::actions::approvals,
13 crate::handlers::actions::review,
14 crate::handlers::actions::variables,
15 crate::handlers::actions::upsert_variable,
16 crate::handlers::actions::delete_variable,
17 crate::handlers::actions::download_actions_artifact,
18 crate::handlers::runs::list_runs,
19 crate::handlers::runs::get_run,
20 crate::handlers::runs::cancel_run,
21 crate::handlers::runs::rerun,
22 crate::handlers::runs::trigger,
23 crate::handlers::runs::list_workflows,
24 crate::handlers::logs::read_logs,
25 crate::handlers::artifacts::list_artifacts,
26 crate::handlers::artifacts::download_artifact,
27 crate::handlers::space::list_repo_secrets,
28 crate::handlers::space::upsert_repo_secret,
29 crate::handlers::space::delete_repo_secret,
30 crate::handlers::space::list_space_secrets,
31 crate::handlers::space::list_environments,
32 crate::handlers::space::list_deployments,
33 ),
34 components(schemas(
35 models::ci::workflow_run::WorkflowRunState,
36 models::ci::workflow_run::WorkflowEventKind,
37 models::ci::workflow_job::WorkflowJobState,
38 models::ci::workflow_step::WorkflowStepState,
39 models::ci::workflow_artifact::ArtifactState,
40 models::ci::ci_secret::CiSecretScope,
41 models::ci::ci_deployment::DeploymentState,
42 ci_protocol::dsl::PullRequestAction,
43 AppResponseData<crate::triggers::RunPageDto>,
44 AppResponseData<crate::triggers::RunDetailDto>,
45 AppResponseData<crate::triggers::ArtifactDto>,
46 AppResponseData<crate::logs::LogPageDto>,
47 AppResponseData<crate::handlers::artifacts::ArtifactListDto>,
48 AppResponseData<crate::handlers::space::SecretListDto>,
49 AppResponseData<crate::handlers::space::SecretMutationDto>,
50 AppResponseData<crate::handlers::space::EnvironmentListDto>,
51 AppResponseData<crate::handlers::space::DeploymentListDto>,
52 AppResponseData<crate::handlers::space::WorkflowSummaryList>,
53 NoData,
54 crate::triggers::RunDto,
55 crate::triggers::RunDetailDto,
56 crate::triggers::RunPageDto,
57 crate::triggers::JobDto,
58 crate::triggers::StepDto,
59 crate::triggers::ArtifactDto,
60 crate::triggers::TriggerRunCmd,
61 crate::logs::LogPageDto,
62 crate::logs::LogChunkDto,
63 crate::handlers::artifacts::ArtifactListDto,
64 crate::handlers::space::SecretListDto,
65 crate::handlers::space::SecretMutationDto,
66 crate::handlers::space::EnvironmentListDto,
67 crate::handlers::space::DeploymentListDto,
68 crate::handlers::space::WorkflowSummaryList,
69 crate::handlers::space::UpsertSecretCmd,
70 )),
71 tags((name = "pipelines", description = "CI/CD 流水线、secrets 与部署环境"))
72)]
73pub struct PipelinesApiDoc;
Deleted lib/ci/src/outbound.rs
@@ -1,263 +0,0 @@
1use hmac::{Hmac, KeyInit, Mac};
2use models::webhook::repo_webhook_delivery::{self, RepoWebhookDeliveryStatus};
3use models::webhook::repo_webhooks;
4use sea_orm::sea_query::Expr;
5use sea_orm::{
6 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
7};
8use sha2::Sha256;
9use time::OffsetDateTime;
10use uuid::Uuid;
11
12use crate::errors::CiResult;
13use crate::secrets::decrypt;
14use crate::service::CiService;
15
16const MAX_RESPONSE_BODY: usize = 2048;
17const CLAIM_STUCK_MINUTES: i64 = 10;
18
19pub async fn enqueue_for_repo(
20 service: &CiService,
21 repo_id: Uuid,
22 event_type: &str,
23 payload: &serde_json::Value,
24) -> CiResult<()> {
25 let hooks = repo_webhooks::Entity::find()
26 .filter(repo_webhooks::Column::RepoId.eq(repo_id))
27 .filter(repo_webhooks::Column::Status.eq(models::webhook::webhooks::WebhookStatus::Active))
28 .all(service.db_read())
29 .await?;
30 if hooks.is_empty() {
31 return Ok(());
32 }
33 let now = OffsetDateTime::now_utc();
34 for hook in hooks {
35 let events: Vec<String> = serde_json::from_value(hook.events.clone()).unwrap_or_default();
36 if !events.iter().any(|e| e == event_type || e == "*") {
37 continue;
38 }
39 repo_webhook_delivery::ActiveModel {
40 id: Set(Uuid::now_v7()),
41 webhook_id: Set(hook.id),
42 repo_id: Set(repo_id),
43 event_type: Set(event_type.to_string()),
44 payload: Set(payload.clone()),
45 status: Set(RepoWebhookDeliveryStatus::Pending),
46 attempts: Set(0),
47 next_retry_at: Set(now),
48 response_status: Set(None),
49 response_body: Set(None),
50 last_error: Set(None),
51 created_at: Set(now),
52 updated_at: Set(now),
53 }
54 .insert(service.db_write())
55 .await?;
56 }
57 Ok(())
58}
59
60impl CiService {
61 #[tracing::instrument(name = "ci.deliver_webhooks", skip_all)]
62 pub async fn deliver_pending_repo_webhooks(&self) -> CiResult<u64> {
63 let now = OffsetDateTime::now_utc();
64 let stuck = now - time::Duration::minutes(CLAIM_STUCK_MINUTES);
65 repo_webhook_delivery::Entity::update_many()
66 .col_expr(
67 repo_webhook_delivery::Column::Status,
68 Expr::value(RepoWebhookDeliveryStatus::Pending),
69 )
70 .col_expr(repo_webhook_delivery::Column::UpdatedAt, Expr::value(now))
71 .filter(repo_webhook_delivery::Column::Status.eq(RepoWebhookDeliveryStatus::Delivering))
72 .filter(repo_webhook_delivery::Column::UpdatedAt.lt(stuck))
73 .exec(self.db_write())
74 .await?;
75
76 let due = repo_webhook_delivery::Entity::find()
77 .filter(repo_webhook_delivery::Column::Status.eq(RepoWebhookDeliveryStatus::Pending))
78 .filter(repo_webhook_delivery::Column::NextRetryAt.lte(now))
79 .order_by_asc(repo_webhook_delivery::Column::NextRetryAt)
80 .limit(50)
81 .all(self.db_read())
82 .await?;
83 let count = due.len() as u64;
84 let max_attempts = self.config().ci.outbound_max_attempts;
85 let backoff_base = self.config().ci.outbound_backoff_base_secs;
86 for delivery in due {
87 let claimed = repo_webhook_delivery::Entity::update_many()
88 .col_expr(
89 repo_webhook_delivery::Column::Status,
90 Expr::value(RepoWebhookDeliveryStatus::Delivering),
91 )
92 .col_expr(repo_webhook_delivery::Column::UpdatedAt, Expr::value(now))
93 .filter(repo_webhook_delivery::Column::Id.eq(delivery.id))
94 .filter(
95 repo_webhook_delivery::Column::Status.eq(RepoWebhookDeliveryStatus::Pending),
96 )
97 .exec(self.db_write())
98 .await?;
99 if claimed.rows_affected == 0 {
100 continue;
101 }
102 let hook = repo_webhooks::Entity::find_by_id(delivery.webhook_id)
103 .one(self.db_read())
104 .await?;
105 let Some(hook) = hook else {
106 self.finish_repo_delivery(
107 delivery.id,
108 RepoWebhookDeliveryStatus::Failed,
109 delivery.attempts,
110 None,
111 None,
112 Some("webhook deleted".to_string()),
113 )
114 .await?;
115 continue;
116 };
117 let secret = decrypt(self.encryption_key(), &hook.secret)
118 .unwrap_or_else(|_| hook.secret.clone());
119 let result = self.post_repo_payload(&hook.url, &secret, &delivery).await;
120 match result {
121 Ok((status, body)) => {
122 self.finish_repo_delivery(
123 delivery.id,
124 RepoWebhookDeliveryStatus::Done,
125 delivery.attempts + 1,
126 Some(status),
127 Some(body),
128 None,
129 )
130 .await?;
131 }
132 Err(error) => {
133 let attempts = delivery.attempts + 1;
134 if attempts >= max_attempts {
135 self.finish_repo_delivery(
136 delivery.id,
137 RepoWebhookDeliveryStatus::Failed,
138 attempts,
139 None,
140 None,
141 Some(error),
142 )
143 .await?;
144 } else {
145 let next = OffsetDateTime::now_utc()
146 + time::Duration::seconds(backoff_base * i64::from(attempts));
147 let mut am: repo_webhook_delivery::ActiveModel = delivery.clone().into();
148 am.status = Set(RepoWebhookDeliveryStatus::Pending);
149 am.attempts = Set(attempts);
150 am.next_retry_at = Set(next);
151 am.last_error = Set(Some(error));
152 am.updated_at = Set(OffsetDateTime::now_utc());
153 am.update(self.db_write()).await?;
154 }
155 }
156 }
157 }
158 Ok(count)
159 }
160
161 async fn finish_repo_delivery(
162 &self,
163 id: Uuid,
164 status: RepoWebhookDeliveryStatus,
165 attempts: i32,
166 response_status: Option<i32>,
167 response_body: Option<String>,
168 last_error: Option<String>,
169 ) -> CiResult<()> {
170 let am = repo_webhook_delivery::ActiveModel {
171 id: Set(id),
172 status: Set(status),
173 attempts: Set(attempts),
174 response_status: Set(response_status),
175 response_body: Set(response_body),
176 last_error: Set(last_error),
177 updated_at: Set(OffsetDateTime::now_utc()),
178 ..Default::default()
179 };
180 am.update(self.db_write()).await?;
181 Ok(())
182 }
183
184 async fn post_repo_payload(
185 &self,
186 url: &str,
187 secret: &str,
188 delivery: &repo_webhook_delivery::Model,
189 ) -> Result<(i32, String), String> {
190 let uri: reqwest::Url = url.parse().map_err(|e| format!("invalid url: {e}"))?;
191 if uri.scheme() != "https" && uri.scheme() != "http" {
192 return Err("webhook url must be http(s)".to_string());
193 }
194 let host = uri.host_str().unwrap_or("").to_string();
195 if host.is_empty() {
196 return Err("webhook url has no host".to_string());
197 }
198 let port = uri
199 .port()
200 .unwrap_or(if uri.scheme() == "https" { 443 } else { 80 });
201 let addrs = tokio::net::lookup_host((host.as_str(), port))
202 .await
203 .map_err(|e| format!("dns resolution failed: {e}"))?
204 .collect::<Vec<_>>();
205 if addrs.is_empty() {
206 return Err("dns resolution returned no address".to_string());
207 }
208 if addrs
209 .iter()
210 .any(|addr| !crate::net::is_public_ip(&addr.ip()))
211 {
212 return Err("url resolves to a private or loopback address".to_string());
213 }
214 let body = serde_json::to_vec(&delivery.payload).map_err(|e| e.to_string())?;
215 let mut mac =
216 Hmac::<Sha256>::new_from_slice(secret.as_bytes()).map_err(|e| e.to_string())?;
217 mac.update(&body);
218 let signature = format!(
219 "sha256={}",
220 crate::secrets::sha256_hex(&mac.finalize().into_bytes())
221 );
222 let mut builder = reqwest::Client::builder()
223 .timeout(std::time::Duration::from_secs(
224 self.config().ci.outbound_timeout_secs.max(1),
225 ))
226 .redirect(reqwest::redirect::Policy::none());
227 for addr in &addrs {
228 builder = builder.resolve(&host, *addr);
229 }
230 let client = builder.build().map_err(|e| e.to_string())?;
231 let span = export::telemetry::http_client_span("POST", url);
232 let response = export::telemetry::send(
233 span,
234 client
235 .post(url)
236 .header("content-type", "application/json")
237 .header("x-gitdata-event", &delivery.event_type)
238 .header("x-gitdata-delivery", delivery.id.to_string())
239 .header("x-gitdata-signature", signature)
240 .body(body),
241 )
242 .await
243 .map_err(|e| {
244 export::metrics::ci_webhook_delivery("error");
245 format!("request failed: {e}")
246 })?;
247 let status = response.status().as_u16() as i32;
248 let text = response
249 .text()
250 .await
251 .unwrap_or_default()
252 .chars()
253 .take(MAX_RESPONSE_BODY)
254 .collect::<String>();
255 if (200..300).contains(&status) {
256 export::metrics::ci_webhook_delivery("ok");
257 Ok((status, text))
258 } else {
259 export::metrics::ci_webhook_delivery("error");
260 Err(format!("endpoint returned {status}"))
261 }
262 }
263}
Deleted lib/ci/src/routes.rs
@@ -1,128 +0,0 @@
1use axum::Router;
2use axum::routing::{get, post, put};
3
4use crate::handlers::{artifacts, logs, runner_protocol, runs, space};
5use crate::service::CiService;
6
7pub fn router() -> Router<CiService> {
8 Router::new()
9 .route(
10 "/repos/{space_name}/{repo_name}/pipelines/dispatches",
11 post(crate::handlers::actions::dispatch),
12 )
13 .route(
14 "/repos/{space_name}/{repo_name}/environments",
15 get(crate::handlers::actions::environments),
16 )
17 .route(
18 "/repos/{space_name}/{repo_name}/environments/{name}",
19 put(crate::handlers::actions::upsert_environment),
20 )
21 .route(
22 "/repos/{space_name}/{repo_name}/pipelines/{run_id}/approvals",
23 get(crate::handlers::actions::approvals),
24 )
25 .route(
26 "/repos/{space_name}/{repo_name}/pipelines/{run_id}/approvals/{review_id}",
27 post(crate::handlers::actions::review),
28 )
29 .route(
30 "/repos/{space_name}/{repo_name}/variables",
31 get(crate::handlers::actions::variables),
32 )
33 .route(
34 "/repos/{space_name}/{repo_name}/variables/{name}",
35 put(crate::handlers::actions::upsert_variable)
36 .delete(crate::handlers::actions::delete_variable),
37 )
38 .route(
39 "/repos/{space_name}/{repo_name}/pipelines/actions-artifacts/{artifact_id}/download",
40 get(crate::handlers::actions::download_actions_artifact),
41 )
42 .route(
43 "/repos/{space_name}/{repo_name}/pipelines",
44 get(runs::list_runs),
45 )
46 .route(
47 "/repos/{space_name}/{repo_name}/pipelines/workflows",
48 get(runs::list_workflows),
49 )
50 .route(
51 "/repos/{space_name}/{repo_name}/pipelines/trigger",
52 post(runs::trigger),
53 )
54 .route(
55 "/repos/{space_name}/{repo_name}/pipelines/artifacts/{artifact_id}/download",
56 get(artifacts::download_artifact),
57 )
58 .route(
59 "/repos/{space_name}/{repo_name}/pipelines/{run_id}",
60 get(runs::get_run),
61 )
62 .route(
63 "/repos/{space_name}/{repo_name}/pipelines/{run_id}/cancel",
64 post(runs::cancel_run),
65 )
66 .route(
67 "/repos/{space_name}/{repo_name}/pipelines/{run_id}/rerun",
68 post(runs::rerun),
69 )
70 .route(
71 "/repos/{space_name}/{repo_name}/pipelines/{run_id}/artifacts",
72 get(artifacts::list_artifacts),
73 )
74 .route(
75 "/repos/{space_name}/{repo_name}/pipelines/{run_id}/jobs/{job_id}/logs",
76 get(logs::read_logs),
77 )
78 .route(
79 "/repos/{space_name}/{repo_name}/secrets",
80 get(space::list_repo_secrets),
81 )
82 .route(
83 "/repos/{space_name}/{repo_name}/secrets/{name}",
84 put(space::upsert_repo_secret).delete(space::delete_repo_secret),
85 )
86 .route(
87 "/spaces/{space_name}/secrets",
88 get(space::list_space_secrets),
89 )
90 .route(
91 "/spaces/{space_name}/environments",
92 get(space::list_environments),
93 )
94 .route(
95 "/spaces/{space_name}/environments/{name}/deployments",
96 get(space::list_deployments),
97 )
98}
99
100pub fn runner_router() -> Router<CiService> {
101 Router::new()
102 .route("/runner/register", post(runner_protocol::register))
103 .route("/runner/heartbeat", post(runner_protocol::heartbeat))
104 .route("/runner/jobs/claim", post(runner_protocol::claim))
105 .route("/runner/jobs/{job_id}/start", post(runner_protocol::start))
106 .route("/runner/jobs/{job_id}/renew", post(runner_protocol::renew))
107 .route("/runner/jobs/{job_id}/events", post(runner_protocol::event))
108 .route(
109 "/runner/jobs/{job_id}/steps/{index}",
110 post(runner_protocol::report_step),
111 )
112 .route(
113 "/runner/jobs/{job_id}/logs",
114 post(runner_protocol::upload_logs),
115 )
116 .route(
117 "/runner/jobs/{job_id}/finish",
118 post(runner_protocol::finish),
119 )
120 .route(
121 "/runner/jobs/{job_id}/artifacts/init",
122 post(runner_protocol::init_artifact),
123 )
124 .route(
125 "/runner/jobs/{job_id}/artifacts/{artifact_id}/complete",
126 post(runner_protocol::complete_artifact),
127 )
128}
Deleted lib/ci/src/runners.rs
@@ -1,540 +0,0 @@
1use std::collections::BTreeMap;
2
3use axum::extract::FromRequestParts;
4use axum::http::header;
5use ci_protocol::runner::{LeaseDto, RegisterResponse, StepPayload};
6use models::ci::runner::{self, RunnerState};
7use models::ci::runner_token;
8use models::ci::workflow_job;
9use models::ci::workflow_run;
10use models::ci::workflow_step;
11use sea_orm::{
12 ActiveModelTrait, ColumnTrait, Condition, EntityTrait, QueryFilter, QueryOrder, Set,
13};
14use sha2::{Digest, Sha256};
15use time::OffsetDateTime;
16use uuid::Uuid;
17
18use crate::errors::{CiError, CiResult};
19use crate::secrets::{constant_time_eq, random_secret};
20use crate::service::CiService;
21
22#[derive(Debug, Clone)]
23pub struct RunnerAuth {
24 pub runner_id: Uuid,
25}
26
27pub fn hash_token(secret: &str, salt: &str) -> String {
28 let digest = Sha256::digest(format!("{secret}{salt}").as_bytes());
29 let mut out = String::with_capacity(digest.len() * 2);
30 for byte in digest {
31 out.push_str(&format!("{byte:02x}"));
32 }
33 out
34}
35
36pub fn generate_credential(runner_id: Uuid) -> (String, String, String, String) {
37 let secret = random_secret(32);
38 let salt = random_secret(16);
39 let hash = hash_token(&secret, &salt);
40 let last_eight: String = secret
41 .chars()
42 .rev()
43 .take(8)
44 .collect::<String>()
45 .chars()
46 .rev()
47 .collect();
48 let token = format!("{runner_id}.{secret}");
49 (token, hash, salt, last_eight)
50}
51
52pub async fn verify_runner_token(service: &CiService, token: &str) -> CiResult<Uuid> {
53 let Some((id, secret)) = token.split_once('.') else {
54 return Err(CiError::Unauthorized);
55 };
56 let Ok(runner_id) = Uuid::parse_str(id) else {
57 return Err(CiError::Unauthorized);
58 };
59 let row = runner_token::Entity::find()
60 .filter(runner_token::Column::RunnerId.eq(runner_id))
61 .one(service.db_read())
62 .await?;
63 let Some(row) = row else {
64 return Err(CiError::Unauthorized);
65 };
66 if row.revoked_at.is_some() {
67 return Err(CiError::Unauthorized);
68 }
69 if let Some(expires_at) = row.expires_at
70 && expires_at <= OffsetDateTime::now_utc()
71 {
72 return Err(CiError::Unauthorized);
73 }
74 let computed = hash_token(secret, &row.token_salt);
75 if !constant_time_eq(computed.as_bytes(), row.token_hash.as_bytes()) {
76 return Err(CiError::Unauthorized);
77 }
78 let runner = runner::Entity::find_by_id(runner_id)
79 .one(service.db_read())
80 .await?
81 .ok_or(CiError::Unauthorized)?;
82 if runner.state != RunnerState::Active {
83 return Err(CiError::Forbidden);
84 }
85 Ok(runner_id)
86}
87
88impl FromRequestParts<CiService> for RunnerAuth {
89 type Rejection = crate::errors::ApiError;
90
91 async fn from_request_parts(
92 parts: &mut axum::http::request::Parts,
93 state: &CiService,
94 ) -> Result<Self, Self::Rejection> {
95 let Some(value) = parts.headers.get(header::AUTHORIZATION) else {
96 return Err(crate::errors::ApiError::unauthorized());
97 };
98 let raw = value
99 .to_str()
100 .map_err(|_| crate::errors::ApiError::bad_request("malformed authorization header"))?;
101 let Some(token) = raw.strip_prefix("Bearer ") else {
102 return Err(crate::errors::ApiError::unauthorized());
103 };
104 let runner_id = verify_runner_token(state, token)
105 .await
106 .map_err(crate::errors::ApiError::from)?;
107 Ok(Self { runner_id })
108 }
109}
110
111impl CiService {
112 pub async fn register_runner(
113 &self,
114 token: &str,
115 name: &str,
116 labels: Vec<String>,
117 capacity: i32,
118 version: Option<String>,
119 ) -> CiResult<RegisterResponse> {
120 self.register_runner_inner(token, name, labels, capacity, version)
121 .await
122 }
123
124 pub async fn configure_runner_protocol(
125 &self,
126 runner_id: Uuid,
127 versions: &[u32],
128 capabilities: &[String],
129 ) -> CiResult<u32> {
130 let version = if versions.contains(&2) {
131 2
132 } else if versions.is_empty() || versions.contains(&1) {
133 1
134 } else {
135 return Err(CiError::InvalidArgument(
136 "no common runner protocol version".into(),
137 ));
138 };
139 let row = runner::Entity::find_by_id(runner_id)
140 .one(self.db_write())
141 .await?
142 .ok_or(CiError::NotFound)?;
143 let mut active: runner::ActiveModel = row.into();
144 active.protocol_version = Set(Some(version as i32));
145 active.capabilities = Set(Some(serde_json::to_value(capabilities)?));
146 active.update(self.db_write()).await?;
147 Ok(version)
148 }
149
150 async fn register_runner_inner(
151 &self,
152 token: &str,
153 name: &str,
154 labels: Vec<String>,
155 capacity: i32,
156 version: Option<String>,
157 ) -> CiResult<RegisterResponse> {
158 self.ensure_enabled()?;
159 if name.trim().is_empty() || name.len() > 200 {
160 return Err(CiError::InvalidArgument(
161 "runner name must be 1-200 characters".to_string(),
162 ));
163 }
164 let mut labels: Vec<String> = labels
165 .into_iter()
166 .map(|l| l.trim().to_string())
167 .filter(|l| !l.is_empty())
168 .collect();
169 labels.sort();
170 labels.dedup();
171 if labels.is_empty() {
172 return Err(CiError::InvalidArgument(
173 "runner must declare at least one label".to_string(),
174 ));
175 }
176 if capacity <= 0 || capacity > 256 {
177 return Err(CiError::InvalidArgument(
178 "runner capacity must be between 1 and 256".to_string(),
179 ));
180 }
181
182 let (registration_token, space_scope) = self.verify_registration_token(token).await?;
183
184 let now = OffsetDateTime::now_utc();
185 let runner_id = Uuid::now_v7();
186 runner::ActiveModel {
187 id: Set(runner_id),
188 name: Set(name.trim().to_string()),
189 labels: Set(serde_json::to_value(&labels)?),
190 capacity: Set(capacity),
191 version: Set(version),
192 state: Set(RunnerState::Active),
193 space_id: Set(space_scope),
194 last_seen_at: Set(Some(now)),
195 created_at: Set(now),
196 updated_at: Set(now),
197 ..Default::default()
198 }
199 .insert(self.db_write())
200 .await?;
201
202 let (credential, hash, salt, last_eight) = generate_credential(runner_id);
203 runner_token::ActiveModel {
204 id: Set(Uuid::now_v7()),
205 runner_id: Set(Some(runner_id)),
206 name: Set(format!("runner:{name}")),
207 token_hash: Set(hash),
208 token_salt: Set(salt),
209 token_last_eight: Set(last_eight),
210 space_id: Set(space_scope),
211 single_use: Set(false),
212 used_at: Set(None),
213 revoked_at: Set(None),
214 expires_at: Set(None),
215 created_at: Set(now),
216 updated_at: Set(now),
217 }
218 .insert(self.db_write())
219 .await?;
220
221 if let Some(registration_token_id) = registration_token {
222 let mut am: runner_token::ActiveModel = registration_token_id.into();
223 am.used_at = Set(Some(now));
224 am.updated_at = Set(now);
225 let _ = am.update(self.db_write()).await;
226 }
227
228 tracing::info!(runner_id = %runner_id, name, "runner registered");
229 Ok(RegisterResponse {
230 runner_id,
231 credential,
232 heartbeat_interval_secs: self.config().ci.heartbeat_interval_secs,
233 poll_timeout_secs: self.config().ci.long_poll_timeout_secs,
234 protocol_version: 1,
235 })
236 }
237
238 async fn verify_registration_token(
239 &self,
240 token: &str,
241 ) -> CiResult<(Option<runner_token::Model>, Option<Uuid>)> {
242 let Some((id, secret)) = token.split_once('.') else {
243 return Err(CiError::Unauthorized);
244 };
245 let Ok(token_id) = Uuid::parse_str(id) else {
246 return Err(CiError::Unauthorized);
247 };
248 let row = runner_token::Entity::find_by_id(token_id)
249 .one(self.db_read())
250 .await?
251 .ok_or(CiError::Unauthorized)?;
252 if row.runner_id.is_some() || row.revoked_at.is_some() {
253 return Err(CiError::Unauthorized);
254 }
255 if row.single_use && row.used_at.is_some() {
256 return Err(CiError::Unauthorized);
257 }
258 if let Some(expires_at) = row.expires_at
259 && expires_at <= OffsetDateTime::now_utc()
260 {
261 return Err(CiError::Unauthorized);
262 }
263 let computed = hash_token(secret, &row.token_salt);
264 if !constant_time_eq(computed.as_bytes(), row.token_hash.as_bytes()) {
265 return Err(CiError::Unauthorized);
266 }
267 Ok((Some(row.clone()), row.space_id))
268 }
269
270 pub async fn runner_heartbeat(&self, runner_id: Uuid) -> CiResult<Vec<Uuid>> {
271 let now = OffsetDateTime::now_utc();
272 runner::Entity::update_many()
273 .col_expr(
274 runner::Column::LastSeenAt,
275 sea_orm::sea_query::Expr::value(Some(now)),
276 )
277 .col_expr(
278 runner::Column::UpdatedAt,
279 sea_orm::sea_query::Expr::value(now),
280 )
281 .filter(runner::Column::Id.eq(runner_id))
282 .exec(self.db_write())
283 .await?;
284 self.cancel_list_for_runner(runner_id).await
285 }
286
287 /// Disables runners that stopped heartbeating and revokes their credentials.
288 #[tracing::instrument(name = "ci.reap_runners", skip_all)]
289 pub async fn reap_stale_runners(&self) -> CiResult<u64> {
290 let now = OffsetDateTime::now_utc();
291 let stale_secs = self.config().ci.runner_stale_secs.max(1) as i64;
292 let cutoff = now - time::Duration::seconds(stale_secs);
293 let stale = runner::Entity::find()
294 .filter(runner::Column::State.eq(RunnerState::Active))
295 .filter(
296 Condition::any()
297 .add(runner::Column::LastSeenAt.lt(cutoff))
298 .add(runner::Column::LastSeenAt.is_null()),
299 )
300 .all(self.db_write())
301 .await?;
302 let mut ids = Vec::with_capacity(stale.len());
303 for row in stale {
304 let id = row.id;
305 let mut am: runner::ActiveModel = row.into();
306 am.state = Set(RunnerState::Disabled);
307 am.updated_at = Set(now);
308 am.update(self.db_write()).await?;
309 ids.push(id);
310 }
311 if ids.is_empty() {
312 return Ok(0);
313 }
314 runner_token::Entity::update_many()
315 .col_expr(
316 runner_token::Column::RevokedAt,
317 sea_orm::sea_query::Expr::value(Some(now)),
318 )
319 .col_expr(
320 runner_token::Column::UpdatedAt,
321 sea_orm::sea_query::Expr::value(now),
322 )
323 .filter(runner_token::Column::RunnerId.is_in(ids.clone()))
324 .filter(runner_token::Column::RevokedAt.is_null())
325 .exec(self.db_write())
326 .await?;
327 tracing::info!(count = ids.len(), "stale runners disabled");
328 Ok(ids.len() as u64)
329 }
330
331 pub async fn runner_labels(&self, runner_id: Uuid) -> CiResult<Vec<String>> {
332 let runner = runner::Entity::find_by_id(runner_id)
333 .one(self.db_read())
334 .await?
335 .ok_or(CiError::NotFound)?;
336 Ok(serde_json::from_value(runner.labels).unwrap_or_default())
337 }
338
339 /// Builds the full claim payload for a job that this runner has leased.
340 pub async fn build_job_payload(
341 &self,
342 job_id: Uuid,
343 _runner_id: Uuid,
344 lease_token: String,
345 ) -> CiResult<ci_protocol::runner::JobPayload> {
346 let job = workflow_job::Entity::find_by_id(job_id)
347 .one(self.db_write())
348 .await?
349 .ok_or(CiError::NotFound)?;
350 let run = workflow_run::Entity::find_by_id(job.run_id)
351 .one(self.db_write())
352 .await?
353 .ok_or(CiError::NotFound)?;
354 if job.actions_context.is_some() {
355 return self
356 .build_actions_job_payload(&job, &run, lease_token)
357 .await;
358 }
359 let repo = self.repo_by_id(job.repo_id).await?;
360 let space = self.space_of_repo(&repo).await?;
361 let definition: serde_json::Value = job.definition.clone();
362
363 let steps = workflow_step::Entity::find()
364 .filter(workflow_step::Column::JobId.eq(job_id))
365 .order_by_asc(workflow_step::Column::StepIndex)
366 .all(self.db_read())
367 .await?;
368 let step_env: BTreeMap<String, String> = definition
369 .get("env")
370 .and_then(|v| serde_json::from_value(v.clone()).ok())
371 .unwrap_or_default();
372 let mut step_payloads = Vec::with_capacity(steps.len());
373 for step in steps {
374 step_payloads.push(StepPayload {
375 index: step.step_index,
376 name: step.name.clone(),
377 run: step.script.clone(),
378 shell: step.shell.clone(),
379 working_directory: step.working_directory.clone(),
380 env: step_env.clone(),
381 timeout_secs: definition
382 .get("timeout-minutes")
383 .and_then(|v| v.as_u64())
384 .map(|m| m * 60),
385 });
386 }
387
388 let workflow_env: BTreeMap<String, String> = run
389 .definition
390 .get("env")
391 .and_then(|v| serde_json::from_value(v.clone()).ok())
392 .unwrap_or_default();
393 let mut env = BTreeMap::new();
394 env.insert("GITDATA_RUN_ID".to_string(), run.id.to_string());
395 env.insert("GITDATA_JOB_ID".to_string(), job.id.to_string());
396 env.insert("GITDATA_REPO_ID".to_string(), repo.id.to_string());
397 env.insert("GITDATA_SPACE".to_string(), space.lower_name.clone());
398 env.insert("GITDATA_REPO".to_string(), repo.name.clone());
399 env.insert("GITDATA_SHA".to_string(), run.commit_sha.clone());
400 env.insert("GITDATA_REF".to_string(), run.git_ref.clone());
401 env.insert("GITDATA_EVENT".to_string(), run.event.as_str().to_string());
402 env.insert("GITDATA_WORKFLOW".to_string(), run.workflow_name.clone());
403 env.insert("CI".to_string(), "true".to_string());
404 env.insert("GITDATA_CI".to_string(), "true".to_string());
405 if let Some(branch) = &run.branch {
406 env.insert("GITDATA_BRANCH".to_string(), branch.clone());
407 }
408 if let Some(tag) = &run.tag {
409 env.insert("GITDATA_TAG".to_string(), tag.clone());
410 }
411 if let Some(base_sha) = &run.base_sha {
412 env.insert("GITDATA_BASE_SHA".to_string(), base_sha.clone());
413 }
414 if let Some(base_ref) = &run.base_ref {
415 env.insert("GITDATA_BASE_REF".to_string(), base_ref.clone());
416 }
417 if let Some(number) = run.pr_number {
418 env.insert("GITDATA_PR_NUMBER".to_string(), number.to_string());
419 }
420 if let Some(environment) = &job.environment {
421 env.insert("GITDATA_ENVIRONMENT".to_string(), environment.clone());
422 }
423 for (key, value) in workflow_env {
424 env.insert(key, value);
425 }
426
427 let secrets: BTreeMap<String, String> = self
428 .resolved_secrets(repo.id, space.id, job.environment.as_deref())
429 .await?
430 .into_iter()
431 .collect();
432
433 let contents_write = definition
434 .get("permissions")
435 .and_then(|p| p.get("contents"))
436 .and_then(|c| c.as_str())
437 .is_some_and(|c| c == "write");
438
439 let artifacts: Vec<ci_protocol::runner::ArtifactSpecDto> = definition
440 .get("artifacts")
441 .and_then(|v| serde_json::from_value(v.clone()).ok())
442 .unwrap_or_default();
443
444 let needs_artifacts = self.needs_artifacts(run.id, job.id).await?;
445 let artifacts: Vec<ci_protocol::runner::ArtifactSpecDto> = artifacts
446 .into_iter()
447 .map(|spec| ci_protocol::runner::ArtifactSpecDto {
448 name: spec.name,
449 path: spec.path,
450 always: spec.always,
451 })
452 .collect();
453
454 let clone_url = format!(
455 "{}/{}/{}.git",
456 self.config().git.base_url.trim_end_matches('/'),
457 space.lower_name,
458 repo.name
459 );
460
461 let payload = ci_protocol::runner::JobPayload {
462 job_id: job.id,
463 run_id: run.id,
464 repo_id: repo.id,
465 space_id: space.id,
466 run_attempt: job.attempt,
467 job_name: job.name.clone(),
468 clone_url,
469 commit_sha: run.commit_sha.clone(),
470 git_ref: run.git_ref.clone(),
471 branch: run.branch.clone(),
472 tag: run.tag.clone(),
473 base_sha: run.base_sha.clone(),
474 base_ref: run.base_ref.clone(),
475 pr_number: run.pr_number,
476 event: run.event.as_str().to_string(),
477 workflow_name: run.workflow_name.clone(),
478 workflow_file: run.workflow_file.clone(),
479 env,
480 secrets,
481 allow_git_token: contents_write,
482 container: definition
483 .get("container")
484 .and_then(|v| v.as_str())
485 .map(|s| s.to_string()),
486 timeout_secs: definition
487 .get("timeout-minutes")
488 .and_then(|v| v.as_u64())
489 .map(|m| m * 60)
490 .unwrap_or(self.config().ci.job_timeout_default_secs)
491 .min(self.config().ci.job_timeout_max_secs),
492 steps: step_payloads,
493 artifacts,
494 needs_artifacts,
495 lease: LeaseDto {
496 token: lease_token,
497 expires_at: OffsetDateTime::now_utc()
498 + time::Duration::seconds(self.config().ci.lease_secs as i64),
499 heartbeat_interval_secs: self.config().ci.heartbeat_interval_secs,
500 attempt: job.attempt,
501 },
502 trace: run
503 .event_payload
504 .get("_gitdata_trace")
505 .and_then(|value| value.as_str())
506 .map(str::to_owned),
507 request_id: run
508 .event_payload
509 .get("_gitdata_request_id")
510 .and_then(|value| value.as_str())
511 .map(str::to_owned),
512 actions: None,
513 };
514 Ok(payload)
515 }
516}
517
518impl CiService {
519 /// Returns the claim payload (with a fresh lease token) for a leased job.
520 pub async fn claim_payload(
521 &self,
522 job_id: Uuid,
523 runner_id: Uuid,
524 ) -> CiResult<Option<ci_protocol::runner::JobPayload>> {
525 let job = workflow_job::Entity::find_by_id(job_id)
526 .one(self.db_read())
527 .await?
528 .ok_or(CiError::NotFound)?;
529 if job.runner_id != Some(runner_id) {
530 return Ok(None);
531 }
532 let lease_token = job
533 .lease_token
534 .clone()
535 .unwrap_or_else(|| crate::secrets::random_secret(24));
536 self.build_job_payload(job_id, runner_id, lease_token)
537 .await
538 .map(Some)
539 }
540}
Deleted lib/ci/src/runs.rs
@@ -1,443 +0,0 @@
1use std::collections::BTreeMap;
2
3use ci_protocol::dsl::Job;
4use ci_protocol::events::{CiEvent, PullRequestEvent, PushEvent};
5use models::ci::workflow_job::{self, WorkflowJobState};
6use models::ci::workflow_run::{self, WorkflowEventKind, WorkflowRunState};
7use models::ci::workflow_step::{self, WorkflowStepState};
8use models::repo::repos;
9use models::space::spaces;
10use sea_orm::{
11 ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set,
12 TransactionTrait,
13};
14use time::OffsetDateTime;
15use uuid::Uuid;
16
17use crate::errors::{CiError, CiResult};
18use crate::service::CiService;
19use crate::workflows::LoadedWorkflow;
20
21#[derive(Debug, Clone)]
22pub struct RunSeed {
23 pub event: WorkflowEventKind,
24 pub event_payload: serde_json::Value,
25 pub commit_sha: String,
26 pub git_ref: String,
27 pub branch: Option<String>,
28 pub tag: Option<String>,
29 pub base_sha: Option<String>,
30 pub base_ref: Option<String>,
31 pub pr_number: Option<i64>,
32 pub trigger_user_id: Option<i64>,
33}
34
35impl RunSeed {
36 #[must_use]
37 pub fn facts(&self, environment: Option<&str>) -> crate::share::conditions::EvalFacts {
38 crate::share::conditions::EvalFacts {
39 event: self.event.as_str().to_string(),
40 branch: self.branch.clone(),
41 tag: self.tag.clone(),
42 environment: environment.map(|e| e.to_string()),
43 }
44 }
45}
46
47pub fn push_seeds(event: &PushEvent) -> Vec<RunSeed> {
48 let mut out = Vec::new();
49 for update in &event.refs {
50 if update.is_delete() {
51 continue;
52 }
53 let kind = match update.ref_kind() {
54 Some(kind) => kind,
55 None => continue,
56 };
57 let name = update.short_name().to_string();
58 let (branch, tag) = if kind == "tag" {
59 (None, Some(name.clone()))
60 } else {
61 (Some(name.clone()), None)
62 };
63 out.push(RunSeed {
64 event: WorkflowEventKind::Push,
65 event_payload: serde_json::json!({
66 "ref": update.name,
67 "old_oid": update.old_oid,
68 "new_oid": update.new_oid,
69 "actor_id": event.actor_id,
70 "event_id": event.id,
71 "_gitdata_trace": export::telemetry::traceparent(),
72 "_gitdata_request_id": export::telemetry::request_id(),
73 }),
74 commit_sha: update.new_oid.clone(),
75 git_ref: update.name.clone(),
76 branch,
77 tag,
78 base_sha: None,
79 base_ref: None,
80 pr_number: None,
81 trigger_user_id: event.actor_id,
82 });
83 }
84 out
85}
86
87pub fn pull_request_seed(event: &PullRequestEvent) -> RunSeed {
88 RunSeed {
89 event: WorkflowEventKind::PullRequest,
90 event_payload: serde_json::json!({
91 "action": if event.action==ci_protocol::events::PullRequestAction::Merged{"closed"}else{event.action.as_str()},
92 "number": event.number,
93 "head_ref": event.head_ref,
94 "base_ref": event.base_ref,
95 "head_sha": event.head_sha,
96 "base_sha": event.base_sha,
97 "actor_id": event.actor_id,
98 "event_id": event.id,
99 "pull_request":{"number":event.number,"merged":event.action==ci_protocol::events::PullRequestAction::Merged,"head":{"sha":event.head_sha,"ref":event.head_ref,"repo":{"fork":event.head_repo_id.is_some_and(|id|id!=event.repo_id)}},"base":{"sha":event.base_sha,"ref":event.base_ref}},
100 "_gitdata_trace": export::telemetry::traceparent(),
101 "_gitdata_request_id": export::telemetry::request_id(),
102 }),
103 commit_sha: event.head_sha.clone(),
104 git_ref: format!("refs/pull/{}/head", event.number),
105 branch: Some(event.head_ref.clone()),
106 tag: None,
107 base_sha: Some(event.base_sha.clone()),
108 base_ref: Some(event.base_ref.clone()),
109 pr_number: Some(event.number),
110 trigger_user_id: event.actor_id,
111 }
112}
113
114impl CiService {
115 /// Evaluates every workflow in the repo at the event revision and creates
116 /// the runs whose triggers match.
117 pub async fn handle_event(&self, event: &CiEvent) -> CiResult<Vec<Uuid>> {
118 if !self.enabled() {
119 return Ok(Vec::new());
120 }
121 if let CiEvent::Trigger(event) = event {
122 return self.handle_trigger_event(event).await;
123 }
124 let (repo_id, space_id, seeds) = match event {
125 CiEvent::Push(push) => (push.repo_id, push.space_id, push_seeds(push)),
126 CiEvent::PullRequest(pr) => (pr.repo_id, pr.space_id, vec![pull_request_seed(pr)]),
127 CiEvent::Trigger(_) => unreachable!(),
128 };
129 let repo = repos::Entity::find_by_id(repo_id)
130 .one(self.db_read())
131 .await?
132 .ok_or(CiError::NotFound)?;
133 let space = spaces::Entity::find_by_id(space_id)
134 .one(self.db_read())
135 .await?
136 .ok_or(CiError::NotFound)?;
137
138 let mut created = Vec::new();
139 for seed in seeds {
140 let workflows = self
141 .load_valid_workflows(&space, &repo.name, &seed.commit_sha)
142 .await?;
143 if seed.branch.as_deref() == Some(repo.default_branch.as_str())
144 && seed.event == WorkflowEventKind::Push
145 {
146 self.sync_actions_schedules(repo.id, &workflows).await?;
147 }
148 let paths = if workflows.iter().any(|workflow| {
149 workflow.actions.as_ref().is_some_and(|actions| {
150 actions.document["on"][seed.event.as_str()]
151 .get("paths")
152 .is_some()
153 || actions.document["on"][seed.event.as_str()]
154 .get("paths-ignore")
155 .is_some()
156 })
157 }) {
158 self.actions_changed_paths(repo.id, &seed).await?
159 } else {
160 Vec::new()
161 };
162 for workflow in workflows {
163 if !self
164 .actions_event_matches(&workflow, &seed, paths.clone())
165 .await?
166 {
167 continue;
168 }
169 let run_id = self.create_run(&repo, &space, &seed, &workflow).await?;
170 created.push(run_id);
171 }
172 }
173 Ok(created)
174 }
175
176 pub async fn create_run(
177 &self,
178 repo: &repos::Model,
179 space: &spaces::Model,
180 seed: &RunSeed,
181 workflow: &LoadedWorkflow,
182 ) -> CiResult<Uuid> {
183 let run_id = Uuid::now_v7();
184 let now = OffsetDateTime::now_utc();
185 let mut definition = serde_json::to_value(&workflow.definition)?;
186 if let Some(actions) = &workflow.actions {
187 definition[ci_protocol::actions::SNAPSHOT_KEY] = serde_json::to_value(actions)?;
188 }
189 let event_key = seed
190 .event_payload
191 .get("event_id")
192 .map(|id| format!("{id}:{}:{}", workflow.file, seed.git_ref));
193 if let Some(key) = &event_key
194 && let Some(existing) = workflow_run::Entity::find()
195 .filter(workflow_run::Column::EventKey.eq(key))
196 .one(self.db_write())
197 .await?
198 {
199 self.emit_actions_workflow_run(&existing, "requested")
200 .await?;
201 return Ok(existing.id);
202 }
203 let concurrency_group = workflow
204 .definition
205 .concurrency
206 .as_ref()
207 .map(|c| expand_concurrency_group(&c.group, seed));
208
209 let txn = self.db_write().begin().await?;
210 use sea_orm::ConnectionTrait;
211 txn.execute_unprepared(&format!(
212 "SELECT pg_advisory_xact_lock({})",
213 repo.id.as_u128() as i64
214 ))
215 .await?;
216 if let Some(key) = &event_key
217 && let Some(existing) = workflow_run::Entity::find()
218 .filter(workflow_run::Column::EventKey.eq(key))
219 .one(&txn)
220 .await?
221 {
222 return Ok(existing.id);
223 }
224 let actions_id = if workflow.actions.is_some() {
225 Some(crate::actions::identity(&txn, run_id, "run").await?)
226 } else {
227 None
228 };
229 let run_number = if workflow.actions.is_some() {
230 let row=txn.query_one_raw(sea_orm::Statement::from_sql_and_values(sea_orm::DatabaseBackend::Postgres,"INSERT INTO ci_workflow_counter (repo_id,workflow_file,last_number) VALUES ($1,$2,1) ON CONFLICT (repo_id,workflow_file) DO UPDATE SET last_number=ci_workflow_counter.last_number+1 RETURNING last_number",[repo.id.into(),workflow.file.clone().into()])).await?.ok_or_else(||CiError::Internal("workflow counter returned no row".into()))?;
231 Some(row.try_get("", "last_number")?)
232 } else {
233 None
234 };
235 workflow_run::ActiveModel {
236 id: Set(run_id),
237 repo_id: Set(repo.id),
238 trigger_user_id: Set(seed.trigger_user_id),
239 workflow_file: Set(workflow.file.clone()),
240 workflow_name: Set(workflow.name.clone()),
241 event: Set(seed.event),
242 event_payload: Set(seed.event_payload.clone()),
243 definition: Set(definition.clone()),
244 actions_id: Set(actions_id),
245 run_number: Set(run_number),
246 event_key: Set(event_key),
247 commit_sha: Set(seed.commit_sha.clone()),
248 git_ref: Set(seed.git_ref.clone()),
249 branch: Set(seed.branch.clone()),
250 tag: Set(seed.tag.clone()),
251 base_sha: Set(seed.base_sha.clone()),
252 base_ref: Set(seed.base_ref.clone()),
253 pr_number: Set(seed.pr_number),
254 state: Set(WorkflowRunState::Queued),
255 concurrency_group: Set(concurrency_group),
256 runner_id: Set(None),
257 attempt: Set(0),
258 started_at: Set(None),
259 finished_at: Set(None),
260 created_at: Set(now),
261 updated_at: Set(now),
262 }
263 .insert(&txn)
264 .await?;
265
266 for (job_key, job) in &workflow.definition.jobs {
267 let job_id = Uuid::now_v7();
268 let environment = job.environment.clone();
269 let facts = seed.facts(environment.as_deref());
270 let condition_ok = match &job.condition {
271 Some(condition) => crate::share::conditions::condition_matches(condition, &facts),
272 None => true,
273 };
274 let state = if workflow.actions.is_some() {
275 WorkflowJobState::Pending
276 } else if condition_ok {
277 if job.needs.is_empty() {
278 WorkflowJobState::Queued
279 } else {
280 WorkflowJobState::Pending
281 }
282 } else {
283 WorkflowJobState::Skipped
284 };
285 workflow_job::ActiveModel {
286 id: Set(job_id),
287 run_id: Set(run_id),
288 repo_id: Set(repo.id),
289 space_id: Set(space.id),
290 name: Set(job_key.clone()),
291 job_key: Set(job_key.clone()),
292 definition: Set(serde_json::to_value(job)?),
293 is_template: Set(workflow.actions.is_some()),
294 actions_context: Set(workflow.actions.as_ref().map(|actions| serde_json::json!({"workflow":actions,"job":job_key,"scope":"","inputs":seed.event_payload.get("_gitdata_inputs").or_else(||seed.event_payload.get("inputs")).cloned().unwrap_or(serde_json::json!({})),"kind":"template","expanded":false}))),
295 runs_on: Set(serde_json::to_value(&job.runs_on)?),
296 environment: Set(environment),
297 needs: Set(serde_json::to_value(&job.needs)?),
298 state: Set(state),
299 runner_id: Set(None),
300 lease_token: Set(None),
301 lease_expires_at: Set(None),
302 attempt: Set(0),
303 exit_code: Set(None),
304 error: Set(None),
305 started_at: Set(None),
306 finished_at: Set(None),
307 created_at: Set(now),
308 updated_at: Set(now),
309 ..Default::default()
310 }
311 .insert(&txn)
312 .await?;
313
314 for (index, step) in job.steps.iter().enumerate() {
315 if workflow.actions.is_some() {
316 break;
317 }
318 workflow_step::ActiveModel {
319 id: Set(Uuid::now_v7()),
320 job_id: Set(job_id),
321 step_index: Set(index as i32),
322 name: Set(step
323 .name
324 .clone()
325 .unwrap_or_else(|| format!("step {}", index + 1))),
326 script: Set(step.run.clone()),
327 shell: Set(step.shell.clone()),
328 working_directory: Set(step.working_directory.clone()),
329 state: Set(WorkflowStepState::Pending),
330 exit_code: Set(None),
331 started_at: Set(None),
332 finished_at: Set(None),
333 created_at: Set(now),
334 updated_at: Set(now),
335 ..Default::default()
336 }
337 .insert(&txn)
338 .await?;
339 }
340 }
341
342 txn.commit().await?;
343 if workflow.actions.is_some()
344 && let Some(run) = workflow_run::Entity::find_by_id(run_id)
345 .one(self.db_write())
346 .await?
347 {
348 self.emit_actions_workflow_run(&run, "requested").await?;
349 }
350 tracing::info!(
351 run_id = %run_id,
352 repo_id = %repo.id,
353 workflow = %workflow.file,
354 commit = %seed.commit_sha,
355 "workflow run created"
356 );
357 Ok(run_id)
358 }
359
360 pub async fn rerun(&self, run_id: Uuid, caller: i64) -> CiResult<Uuid> {
361 let run = workflow_run::Entity::find_by_id(run_id)
362 .one(self.db_read())
363 .await?
364 .ok_or(CiError::NotFound)?;
365 if !matches!(
366 run.state,
367 WorkflowRunState::Success | WorkflowRunState::Failure | WorkflowRunState::Cancelled
368 ) {
369 return Err(CiError::Conflict(
370 "only finished runs can be rerun".to_string(),
371 ));
372 }
373 let repo = repos::Entity::find_by_id(run.repo_id)
374 .one(self.db_read())
375 .await?
376 .ok_or(CiError::NotFound)?;
377 let space = self.space_of_repo(&repo).await?;
378 let actions = ci_protocol::actions::from_snapshot(&run.definition);
379 if actions.is_none() {
380 return Err(CiError::InvalidArgument("historical run has no GitHub Actions definition; trigger a workflow under .github/workflows/".into()));
381 }
382 let definition = actions
383 .as_ref()
384 .expect("Actions snapshot checked")
385 .projection();
386 let workflow = LoadedWorkflow {
387 file: run.workflow_file.clone(),
388 name: run.workflow_name.clone(),
389 definition,
390 actions,
391 };
392 let seed = RunSeed {
393 event: run.event,
394 event_payload: {
395 let mut value = run.event_payload.clone();
396 value.as_object_mut().map(|v| v.remove("event_id"));
397 value
398 },
399 commit_sha: run.commit_sha.clone(),
400 git_ref: run.git_ref.clone(),
401 branch: run.branch.clone(),
402 tag: run.tag.clone(),
403 base_sha: run.base_sha.clone(),
404 base_ref: run.base_ref.clone(),
405 pr_number: run.pr_number,
406 trigger_user_id: Some(caller),
407 };
408 self.create_run(&repo, &space, &seed, &workflow).await
409 }
410}
411
412#[must_use]
413pub fn expand_concurrency_group(group: &str, seed: &RunSeed) -> String {
414 let ref_name = seed
415 .branch
416 .clone()
417 .or_else(|| seed.tag.clone())
418 .unwrap_or_else(|| seed.git_ref.clone());
419 group
420 .replace("{ref}", &ref_name)
421 .replace("{branch}", seed.branch.as_deref().unwrap_or_default())
422 .replace("{tag}", seed.tag.as_deref().unwrap_or_default())
423 .replace("{event}", seed.event.as_str())
424}
425
426#[must_use]
427pub fn job_from_definition(run_definition: &serde_json::Value, job_key: &str) -> Option<Job> {
428 let jobs: BTreeMap<String, Job> =
429 serde_json::from_value(run_definition.get("jobs")?.clone()).ok()?;
430 jobs.get(job_key).cloned()
431}
432
433pub async fn touch_run(db: &DatabaseConnection, run_id: Uuid) -> CiResult<()> {
434 workflow_run::Entity::update_many()
435 .col_expr(
436 workflow_run::Column::UpdatedAt,
437 sea_orm::sea_query::Expr::value(OffsetDateTime::now_utc()),
438 )
439 .filter(workflow_run::Column::Id.eq(run_id))
440 .exec(db)
441 .await?;
442 Ok(())
443}
Deleted lib/ci/src/scheduler.rs
@@ -1,1083 +0,0 @@
1use std::collections::HashMap;
2
3use ci_protocol::runner::{JobFinishState, StepReportState};
4use models::ci::workflow_job::{self, WorkflowJobState};
5use models::ci::workflow_run::{self, WorkflowRunState};
6use models::ci::workflow_step::{self, WorkflowStepState};
7use sea_orm::sea_query::Expr;
8use sea_orm::{
9 ActiveModelTrait, ColumnTrait, DatabaseBackend, EntityTrait, FromQueryResult, PaginatorTrait,
10 QueryFilter, QueryOrder, QuerySelect, Set, Statement,
11};
12use time::OffsetDateTime;
13use uuid::Uuid;
14
15use crate::errors::{CiError, CiResult};
16use crate::service::CiService;
17
18#[derive(Debug)]
19pub struct GroupRow {
20 pub repo_id: Uuid,
21 pub concurrency_group: String,
22}
23
24impl FromQueryResult for GroupRow {
25 fn from_query_result(res: &sea_orm::QueryResult, _pre: &str) -> Result<Self, sea_orm::DbErr> {
26 Ok(Self {
27 repo_id: res.try_get("", "repo_id")?,
28 concurrency_group: res.try_get("", "concurrency_group")?,
29 })
30 }
31}
32
33trait LabelsExt {
34 fn contains_all(&self, wanted: &[String]) -> bool;
35}
36
37impl LabelsExt for Vec<String> {
38 fn contains_all(&self, wanted: &[String]) -> bool {
39 wanted
40 .iter()
41 .all(|label| self.iter().any(|have| have == label))
42 }
43}
44
45impl CiService {
46 #[tracing::instrument(name = "ci.advance_jobs", skip_all)]
47 pub async fn advance_jobs(&self) -> CiResult<u64> {
48 let runs = workflow_run::Entity::find()
49 .filter(
50 workflow_run::Column::State
51 .is_in([WorkflowRunState::Queued, WorkflowRunState::Running]),
52 )
53 .all(self.db_read())
54 .await?;
55 let mut moved = 0u64;
56 for run in runs {
57 moved += if ci_protocol::actions::from_snapshot(&run.definition).is_some() {
58 self.advance_actions_run(&run).await?
59 } else {
60 self.advance_run(run.id).await?
61 };
62 }
63 Ok(moved)
64 }
65
66 async fn advance_run(&self, run_id: Uuid) -> CiResult<u64> {
67 let jobs = workflow_job::Entity::find()
68 .filter(workflow_job::Column::RunId.eq(run_id))
69 .all(self.db_read())
70 .await?;
71 let by_key: HashMap<String, WorkflowJobState> = jobs
72 .iter()
73 .map(|job| (job.job_key.clone(), job.state))
74 .collect();
75 let mut moved = 0u64;
76 let now = OffsetDateTime::now_utc();
77 for job in &jobs {
78 if job.state != WorkflowJobState::Pending {
79 continue;
80 }
81 let needs: Vec<String> = serde_json::from_value(job.needs.clone()).unwrap_or_default();
82 if needs.is_empty() {
83 continue;
84 }
85 let mut ready = true;
86 let mut blocked = false;
87 for need in &needs {
88 match by_key.get(need) {
89 Some(WorkflowJobState::Success) => {}
90 Some(
91 WorkflowJobState::Failure
92 | WorkflowJobState::TimedOut
93 | WorkflowJobState::Cancelled
94 | WorkflowJobState::Skipped,
95 ) => blocked = true,
96 _ => ready = false,
97 }
98 }
99 if blocked {
100 let updated = workflow_job::Entity::update_many()
101 .col_expr(
102 workflow_job::Column::State,
103 Expr::value(WorkflowJobState::Skipped),
104 )
105 .col_expr(workflow_job::Column::FinishedAt, Expr::value(Some(now)))
106 .col_expr(workflow_job::Column::UpdatedAt, Expr::value(now))
107 .filter(workflow_job::Column::Id.eq(job.id))
108 .filter(workflow_job::Column::State.eq(WorkflowJobState::Pending))
109 .exec(self.db_write())
110 .await?;
111 if updated.rows_affected > 0 {
112 moved += 1;
113 self.skip_steps(job.id).await?;
114 }
115 continue;
116 }
117 if !ready {
118 continue;
119 }
120 let updated = workflow_job::Entity::update_many()
121 .col_expr(
122 workflow_job::Column::State,
123 Expr::value(WorkflowJobState::Queued),
124 )
125 .col_expr(workflow_job::Column::UpdatedAt, Expr::value(now))
126 .filter(workflow_job::Column::Id.eq(job.id))
127 .filter(workflow_job::Column::State.eq(WorkflowJobState::Pending))
128 .exec(self.db_write())
129 .await?;
130 if updated.rows_affected > 0 {
131 moved += 1;
132 }
133 }
134 Ok(moved)
135 }
136
137 async fn skip_steps(&self, job_id: Uuid) -> CiResult<()> {
138 workflow_step::Entity::update_many()
139 .col_expr(
140 workflow_step::Column::State,
141 Expr::value(WorkflowStepState::Skipped),
142 )
143 .filter(workflow_step::Column::JobId.eq(job_id))
144 .filter(workflow_step::Column::State.eq(WorkflowStepState::Pending))
145 .exec(self.db_write())
146 .await?;
147 Ok(())
148 }
149
150 #[tracing::instrument(name = "ci.enforce_concurrency", skip_all)]
151 pub async fn enforce_concurrency(&self) -> CiResult<u64> {
152 let actions_changed = self.enforce_actions_concurrency().await?;
153 let groups: Vec<GroupRow> = GroupRow::find_by_statement(Statement::from_string(
154 DatabaseBackend::Postgres,
155 "SELECT DISTINCT repo_id, concurrency_group FROM workflow_run \
156 WHERE concurrency_group IS NOT NULL \
157 AND state IN (0, 1, 4)"
158 .to_string(),
159 ))
160 .all(self.db_read())
161 .await?;
162
163 let mut affected = actions_changed;
164 for group in groups {
165 let runs = workflow_run::Entity::find()
166 .filter(workflow_run::Column::RepoId.eq(group.repo_id))
167 .filter(workflow_run::Column::ConcurrencyGroup.eq(group.concurrency_group.clone()))
168 .filter(workflow_run::Column::State.is_in([
169 WorkflowRunState::Queued,
170 WorkflowRunState::Running,
171 WorkflowRunState::CancelRequested,
172 ]))
173 .order_by_asc(workflow_run::Column::CreatedAt)
174 .all(self.db_read())
175 .await?;
176 if runs.len() < 2 {
177 continue;
178 }
179 if runs
180 .iter()
181 .any(|run| ci_protocol::actions::from_snapshot(&run.definition).is_some())
182 {
183 continue;
184 }
185 let oldest = &runs[0];
186 for run in runs.iter().skip(1) {
187 let cancel_in_progress = run
188 .definition
189 .get("concurrency")
190 .and_then(|c| c.get("cancel_in_progress"))
191 .and_then(|v| v.as_bool())
192 .unwrap_or(false);
193 if !cancel_in_progress {
194 continue;
195 }
196 if matches!(
197 oldest.state,
198 WorkflowRunState::Running | WorkflowRunState::CancelRequested
199 ) {
200 let now = OffsetDateTime::now_utc();
201 let updated = workflow_run::Entity::update_many()
202 .col_expr(
203 workflow_run::Column::State,
204 Expr::value(WorkflowRunState::CancelRequested),
205 )
206 .col_expr(workflow_run::Column::UpdatedAt, Expr::value(now))
207 .filter(workflow_run::Column::Id.eq(oldest.id))
208 .filter(workflow_run::Column::State.eq(WorkflowRunState::Running))
209 .exec(self.db_write())
210 .await?;
211 if updated.rows_affected > 0 {
212 affected += 1;
213 self.record_event(
214 oldest.id,
215 "workflow_run",
216 serde_json::json!({
217 "action": "cancel_requested",
218 "reason": "concurrency",
219 }),
220 )
221 .await;
222 }
223 }
224 break;
225 }
226 }
227 Ok(affected)
228 }
229
230 pub async fn claim_job(&self, runner_id: Uuid, labels: &[String]) -> CiResult<Option<Uuid>> {
231 let runner = models::ci::runner::Entity::find_by_id(runner_id)
232 .one(self.db_read())
233 .await?
234 .ok_or(CiError::NotFound)?;
235 if runner.state != models::ci::runner::RunnerState::Active {
236 return Err(CiError::Forbidden);
237 }
238 let active = workflow_job::Entity::find()
239 .filter(workflow_job::Column::RunnerId.eq(runner_id))
240 .filter(
241 workflow_job::Column::State
242 .is_in([WorkflowJobState::Claimed, WorkflowJobState::Running]),
243 )
244 .count(self.db_read())
245 .await? as i32;
246 if active >= runner.capacity.max(1) {
247 return Ok(None);
248 }
249 let jobs = workflow_job::Entity::find()
250 .filter(workflow_job::Column::State.eq(WorkflowJobState::Queued))
251 .order_by_asc(workflow_job::Column::CreatedAt)
252 .limit(200)
253 .all(self.db_read())
254 .await?;
255 let now = OffsetDateTime::now_utc();
256 for job in jobs {
257 if job.is_template {
258 continue;
259 }
260 if job.actions_context.is_some()
261 && (runner.protocol_version.unwrap_or(1) < 2
262 || !runner
263 .capabilities
264 .as_ref()
265 .and_then(|v| v.as_array())
266 .is_some_and(|v| v.iter().any(|c| c.as_str() == Some("actions-v2"))))
267 {
268 continue;
269 }
270 let job_labels: Vec<String> =
271 serde_json::from_value(job.runs_on.clone()).unwrap_or_default();
272 if !labels.to_vec().contains_all(&job_labels) {
273 continue;
274 }
275 if let Some(space_id) = runner.space_id
276 && job.space_id != space_id
277 {
278 continue;
279 }
280 if job.actions_context.is_some() {
281 if self.claim_actions_job(&runner, &job, labels).await? {
282 self.mark_run_running(job.run_id, runner_id).await?;
283 self.start_deployment(&job).await?;
284 return Ok(Some(job.id));
285 }
286 continue;
287 }
288 if job.actions_context.is_none()
289 && let Some(environment) = &job.environment
290 && !self
291 .runner_matches_environment(job.space_id, environment, labels)
292 .await?
293 {
294 continue;
295 }
296 if job.actions_context.is_none()
297 && !self
298 .environment_available(job.space_id, job.environment.as_ref())
299 .await?
300 {
301 continue;
302 }
303 let lease_token = crate::secrets::random_secret(24);
304 let lease_expires = now + time::Duration::seconds(self.config().ci.lease_secs as i64);
305 let updated = workflow_job::Entity::update_many()
306 .col_expr(
307 workflow_job::Column::State,
308 Expr::value(WorkflowJobState::Claimed),
309 )
310 .col_expr(workflow_job::Column::RunnerId, Expr::value(runner_id))
311 .col_expr(
312 workflow_job::Column::LeaseToken,
313 Expr::value(lease_token.clone()),
314 )
315 .col_expr(
316 workflow_job::Column::LeaseExpiresAt,
317 Expr::value(Some(lease_expires)),
318 )
319 .col_expr(workflow_job::Column::Attempt, Expr::value(job.attempt + 1))
320 .col_expr(workflow_job::Column::UpdatedAt, Expr::value(now))
321 .filter(workflow_job::Column::Id.eq(job.id))
322 .filter(workflow_job::Column::State.eq(WorkflowJobState::Queued))
323 .exec(self.db_write())
324 .await?;
325 if updated.rows_affected == 0 {
326 continue;
327 }
328 self.mark_run_running(job.run_id, runner_id).await?;
329 self.start_deployment(&job).await?;
330 tracing::info!(
331 job_id = %job.id,
332 run_id = %job.run_id,
333 runner_id = %runner_id,
334 attempt = job.attempt + 1,
335 "job claimed"
336 );
337 return Ok(Some(job.id));
338 }
339 Ok(None)
340 }
341
342 async fn runner_matches_environment(
343 &self,
344 space_id: Uuid,
345 environment: &str,
346 labels: &[String],
347 ) -> CiResult<bool> {
348 let row = models::ci::ci_environment::Entity::find()
349 .filter(models::ci::ci_environment::Column::SpaceId.eq(space_id))
350 .filter(models::ci::ci_environment::Column::Name.eq(environment.to_string()))
351 .one(self.db_read())
352 .await?;
353 let Some(row) = row else {
354 return Ok(true);
355 };
356 let required: Vec<String> =
357 serde_json::from_value(row.required_labels.clone()).unwrap_or_default();
358 Ok(labels.to_vec().contains_all(&required))
359 }
360
361 async fn environment_available(
362 &self,
363 space_id: Uuid,
364 environment: Option<&String>,
365 ) -> CiResult<bool> {
366 let Some(environment) = environment else {
367 return Ok(true);
368 };
369 let row = models::ci::ci_environment::Entity::find()
370 .filter(models::ci::ci_environment::Column::SpaceId.eq(space_id))
371 .filter(models::ci::ci_environment::Column::Name.eq(environment.to_string()))
372 .one(self.db_read())
373 .await?;
374 let Some(row) = row else {
375 return Ok(true);
376 };
377 let active = models::ci::ci_deployment::Entity::find()
378 .filter(models::ci::ci_deployment::Column::EnvironmentId.eq(row.id))
379 .filter(models::ci::ci_deployment::Column::State.is_in([
380 models::ci::ci_deployment::DeploymentState::Pending,
381 models::ci::ci_deployment::DeploymentState::Running,
382 ]))
383 .count(self.db_read())
384 .await?;
385 Ok(active == 0)
386 }
387
388 async fn start_deployment(&self, job: &workflow_job::Model) -> CiResult<()> {
389 let Some(environment) = &job.environment else {
390 return Ok(());
391 };
392 let env_row = models::ci::ci_environment::Entity::find()
393 .filter(models::ci::ci_environment::Column::SpaceId.eq(job.space_id))
394 .filter(models::ci::ci_environment::Column::Name.eq(environment.clone()))
395 .one(self.db_read())
396 .await?;
397 let Some(env_row) = env_row else {
398 return Ok(());
399 };
400 let run = workflow_run::Entity::find_by_id(job.run_id)
401 .one(self.db_read())
402 .await?
403 .ok_or(CiError::NotFound)?;
404 let now = OffsetDateTime::now_utc();
405 let existing = models::ci::ci_deployment::Entity::find()
406 .filter(models::ci::ci_deployment::Column::JobId.eq(job.id))
407 .one(self.db_read())
408 .await?;
409 if existing.is_some() {
410 return Ok(());
411 }
412 models::ci::ci_deployment::ActiveModel {
413 id: Set(Uuid::now_v7()),
414 environment_id: Set(env_row.id),
415 run_id: Set(job.run_id),
416 job_id: Set(job.id),
417 repo_id: Set(job.repo_id),
418 commit_sha: Set(run.commit_sha.clone()),
419 git_ref: Set(run.git_ref.clone()),
420 state: Set(models::ci::ci_deployment::DeploymentState::Running),
421 actor_id: Set(run.trigger_user_id),
422 created_at: Set(now),
423 updated_at: Set(now),
424 }
425 .insert(self.db_write())
426 .await?;
427 Ok(())
428 }
429
430 pub async fn finish_deployment(
431 &self,
432 job_id: Uuid,
433 state: models::ci::ci_deployment::DeploymentState,
434 ) -> CiResult<()> {
435 let now = OffsetDateTime::now_utc();
436 models::ci::ci_deployment::Entity::update_many()
437 .col_expr(models::ci::ci_deployment::Column::State, Expr::value(state))
438 .col_expr(
439 models::ci::ci_deployment::Column::UpdatedAt,
440 Expr::value(now),
441 )
442 .filter(models::ci::ci_deployment::Column::JobId.eq(job_id))
443 .exec(self.db_write())
444 .await?;
445 Ok(())
446 }
447
448 async fn mark_run_running(&self, run_id: Uuid, runner_id: Uuid) -> CiResult<()> {
449 let now = OffsetDateTime::now_utc();
450 let updated = workflow_run::Entity::update_many()
451 .col_expr(
452 workflow_run::Column::State,
453 Expr::value(WorkflowRunState::Running),
454 )
455 .col_expr(workflow_run::Column::RunnerId, Expr::value(runner_id))
456 .col_expr(workflow_run::Column::StartedAt, Expr::value(Some(now)))
457 .col_expr(workflow_run::Column::UpdatedAt, Expr::value(now))
458 .filter(workflow_run::Column::Id.eq(run_id))
459 .filter(workflow_run::Column::State.eq(WorkflowRunState::Queued))
460 .exec(self.db_write())
461 .await?;
462 if updated.rows_affected > 0
463 && let Some(run) = workflow_run::Entity::find_by_id(run_id)
464 .one(self.db_write())
465 .await?
466 {
467 self.emit_actions_workflow_run(&run, "in_progress").await?;
468 }
469 Ok(())
470 }
471
472 pub async fn renew_lease(&self, job_id: Uuid, runner_id: Uuid) -> CiResult<()> {
473 let now = OffsetDateTime::now_utc();
474 let expires = now + time::Duration::seconds(self.config().ci.lease_secs as i64);
475 let updated = workflow_job::Entity::update_many()
476 .col_expr(
477 workflow_job::Column::LeaseExpiresAt,
478 Expr::value(Some(expires)),
479 )
480 .col_expr(workflow_job::Column::UpdatedAt, Expr::value(now))
481 .filter(workflow_job::Column::Id.eq(job_id))
482 .filter(workflow_job::Column::RunnerId.eq(runner_id))
483 .filter(
484 workflow_job::Column::State
485 .is_in([WorkflowJobState::Claimed, WorkflowJobState::Running]),
486 )
487 .exec(self.db_write())
488 .await?;
489 if updated.rows_affected == 0 {
490 return Err(CiError::NotFound);
491 }
492 Ok(())
493 }
494
495 #[tracing::instrument(name = "ci.expire_leases", skip_all)]
496 pub async fn expire_leases(&self) -> CiResult<u64> {
497 let now = OffsetDateTime::now_utc();
498 let max_attempts = self.config().ci.max_attempts;
499 let jobs = workflow_job::Entity::find()
500 .filter(
501 workflow_job::Column::State
502 .is_in([WorkflowJobState::Claimed, WorkflowJobState::Running]),
503 )
504 .filter(workflow_job::Column::LeaseExpiresAt.lt(now))
505 .all(self.db_read())
506 .await?;
507 let mut affected = 0u64;
508 for job in jobs {
509 if job.attempt >= max_attempts {
510 workflow_job::Entity::update_many()
511 .col_expr(
512 workflow_job::Column::State,
513 Expr::value(WorkflowJobState::Failure),
514 )
515 .col_expr(
516 workflow_job::Column::Error,
517 Expr::value(Some("runner lost".to_string())),
518 )
519 .col_expr(workflow_job::Column::FinishedAt, Expr::value(Some(now)))
520 .col_expr(workflow_job::Column::RunnerId, Expr::value(None::<Uuid>))
521 .col_expr(
522 workflow_job::Column::LeaseToken,
523 Expr::value(None::<String>),
524 )
525 .col_expr(
526 workflow_job::Column::LeaseExpiresAt,
527 Expr::value(None::<OffsetDateTime>),
528 )
529 .col_expr(workflow_job::Column::UpdatedAt, Expr::value(now))
530 .filter(workflow_job::Column::Id.eq(job.id))
531 .exec(self.db_write())
532 .await?;
533 self.finish_deployment(job.id, models::ci::ci_deployment::DeploymentState::Failure)
534 .await?;
535 affected += 1;
536 self.record_event(
537 job.run_id,
538 "workflow_job",
539 serde_json::json!({
540 "action": "failed",
541 "job": job.name,
542 "reason": "runner lost",
543 }),
544 )
545 .await;
546 } else {
547 workflow_job::Entity::update_many()
548 .col_expr(
549 workflow_job::Column::State,
550 Expr::value(WorkflowJobState::Queued),
551 )
552 .col_expr(workflow_job::Column::RunnerId, Expr::value(None::<Uuid>))
553 .col_expr(
554 workflow_job::Column::LeaseToken,
555 Expr::value(None::<String>),
556 )
557 .col_expr(
558 workflow_job::Column::LeaseExpiresAt,
559 Expr::value(None::<OffsetDateTime>),
560 )
561 .col_expr(workflow_job::Column::UpdatedAt, Expr::value(now))
562 .filter(workflow_job::Column::Id.eq(job.id))
563 .exec(self.db_write())
564 .await?;
565 self.finish_deployment(
566 job.id,
567 models::ci::ci_deployment::DeploymentState::Cancelled,
568 )
569 .await?;
570 affected += 1;
571 tracing::warn!(
572 job_id = %job.id,
573 run_id = %job.run_id,
574 attempt = job.attempt,
575 "job lease expired; requeued"
576 );
577 }
578 }
579 Ok(affected)
580 }
581
582 #[tracing::instrument(name = "ci.apply_cancellations", skip_all)]
583 pub async fn apply_cancellations(&self) -> CiResult<u64> {
584 let now = OffsetDateTime::now_utc();
585 let grace = self.config().ci.cancel_grace_secs as i64;
586 let runs = workflow_run::Entity::find()
587 .filter(workflow_run::Column::State.eq(WorkflowRunState::CancelRequested))
588 .all(self.db_read())
589 .await?;
590 let mut affected = 0u64;
591 for run in runs {
592 workflow_job::Entity::update_many()
593 .col_expr(
594 workflow_job::Column::State,
595 Expr::value(WorkflowJobState::Cancelled),
596 )
597 .col_expr(workflow_job::Column::FinishedAt, Expr::value(Some(now)))
598 .col_expr(workflow_job::Column::UpdatedAt, Expr::value(now))
599 .filter(workflow_job::Column::RunId.eq(run.id))
600 .filter(
601 workflow_job::Column::State
602 .is_in([WorkflowJobState::Pending, WorkflowJobState::Queued]),
603 )
604 .exec(self.db_write())
605 .await?;
606
607 workflow_job::Entity::update_many()
608 .col_expr(
609 workflow_job::Column::State,
610 Expr::value(WorkflowJobState::Cancelled),
611 )
612 .col_expr(
613 workflow_job::Column::Outcome,
614 Expr::value(Some("cancelled".to_owned())),
615 )
616 .col_expr(workflow_job::Column::FinishedAt, Expr::value(Some(now)))
617 .filter(workflow_job::Column::RunId.eq(run.id))
618 .filter(workflow_job::Column::IsTemplate.eq(true))
619 .filter(workflow_job::Column::State.eq(WorkflowJobState::Running))
620 .exec(self.db_write())
621 .await?;
622
623 let stale = now - time::Duration::seconds(grace.max(1));
624 let forced = workflow_job::Entity::update_many()
625 .col_expr(
626 workflow_job::Column::State,
627 Expr::value(WorkflowJobState::Cancelled),
628 )
629 .col_expr(
630 workflow_job::Column::Error,
631 Expr::value(Some("cancelled by janitor".to_string())),
632 )
633 .col_expr(workflow_job::Column::FinishedAt, Expr::value(Some(now)))
634 .col_expr(workflow_job::Column::UpdatedAt, Expr::value(now))
635 .filter(workflow_job::Column::RunId.eq(run.id))
636 .filter(
637 workflow_job::Column::State
638 .is_in([WorkflowJobState::Claimed, WorkflowJobState::Running]),
639 )
640 .filter(workflow_job::Column::UpdatedAt.lt(stale))
641 .exec(self.db_write())
642 .await?;
643 affected += forced.rows_affected;
644
645 let remaining = workflow_job::Entity::find()
646 .filter(workflow_job::Column::RunId.eq(run.id))
647 .all(self.db_read())
648 .await?;
649 if remaining.iter().all(|j| j.state.is_terminal()) {
650 self.finish_run(run.id, WorkflowRunState::Cancelled, None)
651 .await?;
652 affected += 1;
653 }
654 }
655 Ok(affected)
656 }
657
658 #[tracing::instrument(name = "ci.finalize_runs", skip_all)]
659 pub async fn finalize_runs(&self) -> CiResult<u64> {
660 let runs = workflow_run::Entity::find()
661 .filter(
662 workflow_run::Column::State
663 .is_in([WorkflowRunState::Running, WorkflowRunState::Queued]),
664 )
665 .all(self.db_read())
666 .await?;
667 let mut affected = 0u64;
668 for run in runs {
669 let jobs = workflow_job::Entity::find()
670 .filter(workflow_job::Column::RunId.eq(run.id))
671 .all(self.db_read())
672 .await?;
673 if jobs.is_empty() || !jobs.iter().all(|j| j.state.is_terminal()) {
674 continue;
675 }
676 let failed = jobs
677 .iter()
678 .filter(|job| {
679 ci_protocol::actions::from_snapshot(&run.definition).is_none()
680 || (job.is_template && job.logical_job_id.is_none())
681 })
682 .any(|j| {
683 matches!(
684 j.state,
685 WorkflowJobState::Failure
686 | WorkflowJobState::TimedOut
687 | WorkflowJobState::Cancelled
688 )
689 });
690 let state = if failed {
691 WorkflowRunState::Failure
692 } else {
693 WorkflowRunState::Success
694 };
695 self.finish_run(run.id, state, None).await?;
696 affected += 1;
697 }
698 Ok(affected)
699 }
700
701 pub async fn finish_run(
702 &self,
703 run_id: Uuid,
704 state: WorkflowRunState,
705 error: Option<String>,
706 ) -> CiResult<()> {
707 let now = OffsetDateTime::now_utc();
708 let updated = workflow_run::Entity::update_many()
709 .col_expr(workflow_run::Column::State, Expr::value(state))
710 .col_expr(workflow_run::Column::FinishedAt, Expr::value(Some(now)))
711 .col_expr(workflow_run::Column::UpdatedAt, Expr::value(now))
712 .filter(workflow_run::Column::Id.eq(run_id))
713 .filter(workflow_run::Column::State.is_in([
714 WorkflowRunState::Queued,
715 WorkflowRunState::Running,
716 WorkflowRunState::CancelRequested,
717 ]))
718 .exec(self.db_write())
719 .await?;
720 if updated.rows_affected == 0 {
721 return Ok(());
722 }
723 let run = workflow_run::Entity::find_by_id(run_id)
724 .one(self.db_write())
725 .await?
726 .ok_or(CiError::NotFound)?;
727 self.publish_commit_status(&run, state).await?;
728 self.record_event(
729 run_id,
730 "workflow_run",
731 serde_json::json!({
732 "action": "finished",
733 "state": crate::share::states::run_state_str(state),
734 "error": error,
735 }),
736 )
737 .await;
738 tracing::info!(run_id = %run_id, state = ?state, "workflow run finished");
739 self.emit_actions_workflow_run(&run, "completed").await?;
740 Ok(())
741 }
742
743 pub async fn mark_run_cancel_requested(&self, run_id: Uuid) -> CiResult<()> {
744 let now = OffsetDateTime::now_utc();
745 let updated = workflow_run::Entity::update_many()
746 .col_expr(
747 workflow_run::Column::State,
748 Expr::value(WorkflowRunState::CancelRequested),
749 )
750 .col_expr(workflow_run::Column::UpdatedAt, Expr::value(now))
751 .filter(workflow_run::Column::Id.eq(run_id))
752 .filter(
753 workflow_run::Column::State
754 .is_in([WorkflowRunState::Queued, WorkflowRunState::Running]),
755 )
756 .exec(self.db_write())
757 .await?;
758 if updated.rows_affected == 0 {
759 return Err(CiError::Conflict(
760 "only queued or running runs can be cancelled".to_string(),
761 ));
762 }
763 self.record_event(
764 run_id,
765 "workflow_run",
766 serde_json::json!({ "action": "cancel_requested" }),
767 )
768 .await;
769 Ok(())
770 }
771
772 pub async fn cancel_list_for_runner(&self, runner_id: Uuid) -> CiResult<Vec<Uuid>> {
773 let jobs = workflow_job::Entity::find()
774 .filter(workflow_job::Column::RunnerId.eq(runner_id))
775 .filter(
776 workflow_job::Column::State
777 .is_in([WorkflowJobState::Claimed, WorkflowJobState::Running]),
778 )
779 .all(self.db_read())
780 .await?;
781 let mut out = Vec::new();
782 for job in jobs {
783 let run = workflow_run::Entity::find_by_id(job.run_id)
784 .one(self.db_read())
785 .await?;
786 if let Some(run) = run
787 && run.state == WorkflowRunState::CancelRequested
788 {
789 out.push(job.id);
790 } else if job
791 .actions_context
792 .as_ref()
793 .is_some_and(|context| context["cancel_requested"].as_bool().unwrap_or(false))
794 {
795 out.push(job.id);
796 }
797 }
798 Ok(out)
799 }
800
801 pub async fn start_job(&self, job_id: Uuid, runner_id: Uuid) -> CiResult<Uuid> {
802 let now = OffsetDateTime::now_utc();
803 let updated = workflow_job::Entity::update_many()
804 .col_expr(
805 workflow_job::Column::State,
806 Expr::value(WorkflowJobState::Running),
807 )
808 .col_expr(workflow_job::Column::StartedAt, Expr::value(Some(now)))
809 .col_expr(workflow_job::Column::UpdatedAt, Expr::value(now))
810 .filter(workflow_job::Column::Id.eq(job_id))
811 .filter(workflow_job::Column::RunnerId.eq(runner_id))
812 .filter(
813 workflow_job::Column::State
814 .is_in([WorkflowJobState::Claimed, WorkflowJobState::Running]),
815 )
816 .exec(self.db_write())
817 .await?;
818 if updated.rows_affected == 0 {
819 return Err(CiError::NotFound);
820 }
821 let job = workflow_job::Entity::find_by_id(job_id)
822 .one(self.db_read())
823 .await?
824 .ok_or(CiError::NotFound)?;
825 Ok(job.run_id)
826 }
827
828 pub async fn report_step(
829 &self,
830 job_id: Uuid,
831 runner_id: Uuid,
832 index: i32,
833 report: &ci_protocol::runner::StepReport,
834 ) -> CiResult<()> {
835 self.ensure_job_owned(job_id, runner_id).await?;
836 let now = OffsetDateTime::now_utc();
837 let (state, exit_code, started_at, finished_at) = match report.state {
838 StepReportState::Started => (
839 WorkflowStepState::Running,
840 None,
841 report.started_at.or(Some(now)),
842 None,
843 ),
844 StepReportState::Finished => {
845 let exit = report.exit_code.unwrap_or(0);
846 let state = if exit == 0 {
847 WorkflowStepState::Success
848 } else {
849 WorkflowStepState::Failure
850 };
851 (
852 state,
853 Some(exit),
854 report.started_at,
855 report.finished_at.or(Some(now)),
856 )
857 }
858 };
859 let step = workflow_step::Entity::find()
860 .filter(workflow_step::Column::JobId.eq(job_id))
861 .filter(workflow_step::Column::StepIndex.eq(index))
862 .one(self.db_read())
863 .await?
864 .ok_or(CiError::NotFound)?;
865 let mut am: workflow_step::ActiveModel = step.into();
866 am.state = Set(state);
867 am.exit_code = Set(exit_code);
868 if let Some(name) = &report.name {
869 am.name = Set(name.clone());
870 }
871 if let Some(shell) = &report.shell {
872 am.shell = Set(Some(shell.clone()));
873 }
874 if let Some(workdir) = &report.working_directory {
875 am.working_directory = Set(Some(workdir.clone()));
876 }
877 if let Some(script) = &report.script {
878 am.script = Set(script.clone());
879 }
880 if let Some(started) = started_at {
881 am.started_at = Set(Some(started));
882 }
883 if let Some(finished) = finished_at {
884 am.finished_at = Set(Some(finished));
885 }
886 am.updated_at = Set(now);
887 am.update(self.db_write()).await?;
888 Ok(())
889 }
890
891 pub async fn ensure_job_owned(&self, job_id: Uuid, runner_id: Uuid) -> CiResult<()> {
892 let job = workflow_job::Entity::find_by_id(job_id)
893 .one(self.db_read())
894 .await?
895 .ok_or(CiError::NotFound)?;
896 if job.runner_id != Some(runner_id) {
897 return Err(CiError::Forbidden);
898 }
899 Ok(())
900 }
901
902 pub async fn finish_job(
903 &self,
904 job_id: Uuid,
905 runner_id: Uuid,
906 finish: &ci_protocol::runner::JobFinishRequest,
907 ) -> CiResult<Uuid> {
908 let job = workflow_job::Entity::find_by_id(job_id)
909 .one(self.db_read())
910 .await?
911 .ok_or(CiError::NotFound)?;
912 if job.runner_id != Some(runner_id) {
913 return Err(CiError::Forbidden);
914 }
915 if job.state.is_terminal() {
916 return Ok(job.run_id);
917 }
918 if finish.lease_token.is_some() && finish.lease_token != job.lease_token {
919 return Err(CiError::Conflict("job lease was superseded".into()));
920 }
921 let now = OffsetDateTime::now_utc();
922 let state = match finish.state {
923 JobFinishState::Success => WorkflowJobState::Success,
924 JobFinishState::Failure => WorkflowJobState::Failure,
925 JobFinishState::TimedOut => WorkflowJobState::TimedOut,
926 JobFinishState::Cancelled => WorkflowJobState::Cancelled,
927 };
928 let updated = workflow_job::Entity::update_many()
929 .col_expr(workflow_job::Column::State, Expr::value(state))
930 .col_expr(
931 workflow_job::Column::Outputs,
932 Expr::value(Some(serde_json::to_value(&finish.outputs)?)),
933 )
934 .col_expr(
935 workflow_job::Column::Outcome,
936 Expr::value(Some(state.as_str().to_owned())),
937 )
938 .col_expr(
939 workflow_job::Column::ExitCode,
940 Expr::value(finish.exit_code),
941 )
942 .col_expr(
943 workflow_job::Column::Error,
944 Expr::value(finish.error.clone()),
945 )
946 .col_expr(workflow_job::Column::FinishedAt, Expr::value(Some(now)))
947 .col_expr(
948 workflow_job::Column::LeaseToken,
949 Expr::value(None::<String>),
950 )
951 .col_expr(
952 workflow_job::Column::LeaseExpiresAt,
953 Expr::value(None::<OffsetDateTime>),
954 )
955 .col_expr(workflow_job::Column::UpdatedAt, Expr::value(now))
956 .filter(workflow_job::Column::Id.eq(job_id))
957 .filter(workflow_job::Column::RunnerId.eq(runner_id))
958 .filter(
959 sea_orm::Condition::all().add_option(
960 finish
961 .lease_token
962 .as_ref()
963 .map(|token| workflow_job::Column::LeaseToken.eq(token.clone())),
964 ),
965 )
966 .exec(self.db_write())
967 .await?;
968 if updated.rows_affected == 0 {
969 return Ok(job.run_id);
970 }
971 let duration = job
972 .started_at
973 .and_then(|started| (now - started).try_into().ok());
974 export::metrics::ci_job_finished(state.as_str(), duration);
975 if state != WorkflowJobState::Success {
976 workflow_step::Entity::update_many()
977 .col_expr(
978 workflow_step::Column::State,
979 Expr::value(WorkflowStepState::Cancelled),
980 )
981 .filter(workflow_step::Column::JobId.eq(job_id))
982 .filter(
983 workflow_step::Column::State
984 .is_in([WorkflowStepState::Pending, WorkflowStepState::Running]),
985 )
986 .exec(self.db_write())
987 .await?;
988 }
989 let deployment_state = match state {
990 WorkflowJobState::Success => models::ci::ci_deployment::DeploymentState::Success,
991 WorkflowJobState::Cancelled => models::ci::ci_deployment::DeploymentState::Cancelled,
992 _ => models::ci::ci_deployment::DeploymentState::Failure,
993 };
994 self.finish_deployment(job.id, deployment_state).await?;
995 self.record_event(
996 job.run_id,
997 "workflow_job",
998 serde_json::json!({
999 "action": "finished",
1000 "job": job.name,
1001 "state": state.as_str(),
1002 "exit_code": finish.exit_code,
1003 }),
1004 )
1005 .await;
1006 Ok(job.run_id)
1007 }
1008
1009 pub async fn record_event(&self, run_id: Uuid, event_type: &str, payload: serde_json::Value) {
1010 let run = match workflow_run::Entity::find_by_id(run_id)
1011 .one(self.db_read())
1012 .await
1013 {
1014 Ok(Some(run)) => run,
1015 Ok(None) => return,
1016 Err(error) => {
1017 tracing::warn!(%error, "ci event run lookup failed");
1018 return;
1019 }
1020 };
1021 if let Err(error) =
1022 crate::outbound::enqueue_for_repo(self, run.repo_id, event_type, &payload).await
1023 {
1024 tracing::warn!(%error, "outbound webhook enqueue failed");
1025 }
1026 }
1027
1028 pub async fn repo_by_id(&self, repo_id: Uuid) -> CiResult<models::repo::repos::Model> {
1029 models::repo::repos::Entity::find_by_id(repo_id)
1030 .one(self.db_read())
1031 .await?
1032 .ok_or(CiError::NotFound)
1033 }
1034
1035 async fn publish_commit_status(
1036 &self,
1037 run: &workflow_run::Model,
1038 state: WorkflowRunState,
1039 ) -> CiResult<()> {
1040 let Some(creator) = run.trigger_user_id else {
1041 tracing::warn!(run_id = %run.id, "run has no trigger user; skipping commit status");
1042 return Ok(());
1043 };
1044 let repo = self.repo_by_id(run.repo_id).await?;
1045 let space = self.space_of_repo(&repo).await?;
1046 let git = git::service::GitService::new(self.state.clone());
1047 let dto_state = match state {
1048 WorkflowRunState::Success => git::service::commits::CommitStatusDtoState::Success,
1049 WorkflowRunState::Failure => git::service::commits::CommitStatusDtoState::Failure,
1050 WorkflowRunState::Cancelled => git::service::commits::CommitStatusDtoState::Error,
1051 _ => git::service::commits::CommitStatusDtoState::Pending,
1052 };
1053 let description = match state {
1054 WorkflowRunState::Success => "workflow succeeded",
1055 WorkflowRunState::Failure => "workflow failed",
1056 WorkflowRunState::Cancelled => "workflow cancelled",
1057 _ => "workflow running",
1058 };
1059 let base_url = self.config().git.base_url.trim_end_matches('/').to_string();
1060 let target_url = format!(
1061 "{base_url}/app/{}/repo/{}?tab=pipelines&run={}",
1062 space.lower_name, repo.name, run.id
1063 );
1064 if let Err(error) = git
1065 .set_commit_status(
1066 space.lower_name.clone(),
1067 repo.name.clone(),
1068 creator,
1069 git::service::commits::SetCommitStatusCmd {
1070 sha: run.commit_sha.clone(),
1071 state: dto_state,
1072 context: format!("ci/{}", run.workflow_name),
1073 description: Some(description.to_string()),
1074 target_url: Some(target_url),
1075 },
1076 )
1077 .await
1078 {
1079 tracing::warn!(run_id = %run.id, error = %error, "commit status write failed");
1080 }
1081 Ok(())
1082 }
1083}
Deleted lib/ci/src/secrets.rs
@@ -1,147 +0,0 @@
1use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
2use aes_gcm::{Aes256Gcm, Nonce};
3use base64::Engine;
4use base64::engine::general_purpose::STANDARD;
5use sha2::{Digest, Sha256};
6
7use crate::errors::{CiError, CiResult};
8
9fn key_material(secret_key: &str) -> [u8; 32] {
10 Sha256::digest(secret_key.as_bytes()).into()
11}
12
13pub fn encrypt(secret_key: &str, plaintext: &str) -> CiResult<String> {
14 let key = key_material(secret_key);
15 let cipher = Aes256Gcm::new((&key).into());
16 let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
17 let ciphertext = cipher
18 .encrypt(&nonce, plaintext.as_bytes())
19 .map_err(|_| CiError::Internal("secret encryption failed".to_string()))?;
20 let mut output = Vec::with_capacity(nonce.len() + ciphertext.len());
21 output.extend_from_slice(&nonce);
22 output.extend_from_slice(&ciphertext);
23 Ok(STANDARD.encode(output))
24}
25
26pub fn decrypt(secret_key: &str, stored: &str) -> CiResult<String> {
27 let bytes = STANDARD
28 .decode(stored)
29 .map_err(|_| CiError::Internal("malformed encrypted secret".to_string()))?;
30 if bytes.len() < 12 {
31 return Err(CiError::Internal("malformed encrypted secret".to_string()));
32 }
33 let (nonce_bytes, ciphertext) = bytes.split_at(12);
34 let key = key_material(secret_key);
35 let cipher = Aes256Gcm::new((&key).into());
36 let mut nonce_arr = [0u8; 12];
37 nonce_arr.copy_from_slice(nonce_bytes);
38 let nonce = Nonce::from(nonce_arr);
39 let plaintext = cipher
40 .decrypt(&nonce, ciphertext)
41 .map_err(|_| CiError::Internal("secret decryption failed".to_string()))?;
42 String::from_utf8(plaintext)
43 .map_err(|_| CiError::Internal("decrypted secret is not utf8".to_string()))
44}
45
46pub fn sha256_hex(data: &[u8]) -> String {
47 let digest = Sha256::digest(data);
48 let mut out = String::with_capacity(digest.len() * 2);
49 for byte in digest {
50 out.push_str(&format!("{byte:02x}"));
51 }
52 out
53}
54
55pub fn random_secret(bytes: usize) -> String {
56 use rand::RngCore;
57 let mut buf = vec![0u8; bytes];
58 rand::thread_rng().fill_bytes(&mut buf);
59 STANDARD.encode(buf)
60}
61
62pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
63 if a.len() != b.len() {
64 return false;
65 }
66 let mut diff = 0u8;
67 for (x, y) in a.iter().zip(b.iter()) {
68 diff |= x ^ y;
69 }
70 diff == 0
71}
72
73#[must_use]
74pub fn redact(text: &str, secrets: &[String]) -> String {
75 let mut out = text.to_string();
76 for secret in secrets {
77 if secret.len() < 4 {
78 continue;
79 }
80 if out.contains(secret.as_str()) {
81 out = out.replace(secret.as_str(), "***");
82 }
83 }
84 out
85}
86
87use sea_orm::ColumnTrait;
88use sea_orm::EntityTrait;
89use sea_orm::QueryFilter;
90
91impl crate::service::CiService {
92 pub async fn delete_repo_secret(&self, repo_id: uuid::Uuid, name: &str) -> CiResult<()> {
93 let row = models::ci::ci_secret::Entity::find()
94 .filter(models::ci::ci_secret::Column::RepoId.eq(repo_id))
95 .filter(models::ci::ci_secret::Column::Name.eq(name.to_string()))
96 .one(self.db_read())
97 .await?;
98 let Some(row) = row else {
99 return Err(CiError::NotFound);
100 };
101 self.delete_secret(row.id).await
102 }
103
104 pub async fn resolve_space(&self, space_name: &str) -> CiResult<models::space::spaces::Model> {
105 use models::space::spaces;
106 let lower = space_name.trim().to_lowercase();
107 if lower.is_empty() {
108 return Err(CiError::NotFound);
109 }
110 spaces::Entity::find()
111 .filter(spaces::Column::LowerName.eq(lower))
112 .filter(spaces::Column::IsActive.eq(true))
113 .one(self.db_read())
114 .await?
115 .ok_or(CiError::NotFound)
116 }
117
118 pub async fn find_environment(
119 &self,
120 space_id: uuid::Uuid,
121 name: &str,
122 ) -> CiResult<models::ci::ci_environment::Model> {
123 models::ci::ci_environment::Entity::find()
124 .filter(models::ci::ci_environment::Column::SpaceId.eq(space_id))
125 .filter(models::ci::ci_environment::Column::Name.eq(name.to_string()))
126 .one(self.db_read())
127 .await?
128 .ok_or(CiError::NotFound)
129 }
130
131 pub async fn job_row(&self, job_id: uuid::Uuid) -> CiResult<models::ci::workflow_job::Model> {
132 models::ci::workflow_job::Entity::find_by_id(job_id)
133 .one(self.db_read())
134 .await?
135 .ok_or(CiError::NotFound)
136 }
137
138 pub async fn artifact_row(
139 &self,
140 artifact_id: uuid::Uuid,
141 ) -> CiResult<models::ci::workflow_artifact::Model> {
142 models::ci::workflow_artifact::Entity::find_by_id(artifact_id)
143 .one(self.db_read())
144 .await?
145 .ok_or(CiError::NotFound)
146 }
147}
Deleted lib/ci/src/service.rs
@@ -1,128 +0,0 @@
1use std::sync::Arc;
2
3use config::AppConfig;
4use export::AppState;
5use models::repo::repos;
6use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
7
8use crate::errors::{CiError, CiResult};
9
10#[derive(Clone)]
11pub struct CiService {
12 pub state: AppState,
13}
14
15impl CiService {
16 #[must_use]
17 pub fn new(state: AppState) -> Self {
18 Self { state }
19 }
20
21 #[must_use]
22 pub fn config(&self) -> &AppConfig {
23 &self.state.config
24 }
25
26 #[must_use]
27 pub fn db_read(&self) -> &DatabaseConnection {
28 self.state.database.read()
29 }
30
31 #[must_use]
32 pub fn db_write(&self) -> &DatabaseConnection {
33 self.state.database.write()
34 }
35
36 #[must_use]
37 pub fn encryption_key(&self) -> &str {
38 &self.state.config.ci.secret_encryption_key
39 }
40
41 #[must_use]
42 pub fn enabled(&self) -> bool {
43 self.state.config.ci.enabled
44 }
45
46 pub fn ensure_enabled(&self) -> CiResult<()> {
47 if self.enabled() {
48 Ok(())
49 } else {
50 Err(CiError::Forbidden)
51 }
52 }
53
54 pub async fn resolve_repo(
55 &self,
56 space_name: &str,
57 repo_name: &str,
58 ) -> CiResult<(repos::Model, models::space::spaces::Model)> {
59 use models::space::spaces;
60
61 let space_lower = space_name.trim().to_lowercase();
62 if space_lower.is_empty() {
63 return Err(CiError::NotFound);
64 }
65 let space = spaces::Entity::find()
66 .filter(spaces::Column::LowerName.eq(&space_lower))
67 .filter(spaces::Column::IsActive.eq(true))
68 .one(self.db_read())
69 .await?
70 .ok_or(CiError::NotFound)?;
71
72 let repo_lower = repo_name.trim().to_lowercase();
73 if repo_lower.is_empty() {
74 return Err(CiError::NotFound);
75 }
76 let candidates = repos::Entity::find()
77 .filter(repos::Column::LowerName.eq(&repo_lower))
78 .all(self.db_read())
79 .await?;
80 let mut found = None;
81 for candidate in candidates {
82 if git::share::access::space_for_dir(self.db_read(), candidate.dir_id)
83 .await?
84 .is_some_and(|s| s.id == space.id)
85 {
86 found = Some(candidate);
87 break;
88 }
89 }
90 let repo = found.ok_or(CiError::NotFound)?;
91 Ok((repo, space))
92 }
93
94 pub async fn space_of_repo(
95 &self,
96 repo: &repos::Model,
97 ) -> CiResult<models::space::spaces::Model> {
98 let space = git::share::access::space_for_dir(self.db_read(), repo.dir_id)
99 .await?
100 .ok_or(CiError::NotFound)?;
101 Ok(space)
102 }
103
104 #[must_use]
105 pub fn repo_dir(&self, repo_id: uuid::Uuid) -> std::path::PathBuf {
106 std::path::PathBuf::from(&self.state.config.git.repo_root).join(repo_id.to_string())
107 }
108
109 #[must_use]
110 pub fn storage(&self) -> &Arc<export::AppStorage> {
111 &self.state.storage
112 }
113}
114
115impl CiService {
116 /// Builds a `GitContext` for authorization checks without resolving names.
117 pub async fn git_context(
118 &self,
119 repo: &repos::Model,
120 ) -> CiResult<git::context::facade::GitContext> {
121 let space = self.space_of_repo(repo).await?;
122 let service = git::service::GitService::new(self.state.clone());
123 service
124 .context(space.lower_name, repo.name.clone())
125 .await
126 .map_err(CiError::from)
127 }
128}
Deleted lib/ci/src/storage.rs
@@ -1,254 +0,0 @@
1use models::ci::workflow_artifact::{self, ArtifactState};
2use models::ci::workflow_run;
3use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set};
4use time::OffsetDateTime;
5use uuid::Uuid;
6
7use crate::errors::{CiError, CiResult};
8use crate::service::CiService;
9
10impl CiService {
11 pub fn log_object_key(run_id: Uuid, job_id: Uuid, seq: i32) -> String {
12 format!("ci/logs/{run_id}/{job_id}/{seq:06}.log")
13 }
14
15 pub fn artifact_object_key(run_id: Uuid, job_id: Uuid, artifact_id: Uuid) -> String {
16 format!("ci/artifacts/{run_id}/{job_id}/{artifact_id}")
17 }
18
19 pub async fn init_artifact(
20 &self,
21 job_id: Uuid,
22 runner_id: Uuid,
23 name: &str,
24 size: i64,
25 sha256: Option<String>,
26 ) -> CiResult<(Uuid, String)> {
27 self.ensure_job_owned(job_id, runner_id).await?;
28 if size < 0 {
29 return Err(CiError::InvalidArgument(
30 "artifact size must not be negative".to_string(),
31 ));
32 }
33 if size as u64 > self.config().ci.artifact_max_bytes {
34 return Err(CiError::InvalidArgument(
35 "artifact exceeds the configured size limit".to_string(),
36 ));
37 }
38 let job = models::ci::workflow_job::Entity::find_by_id(job_id)
39 .one(self.db_read())
40 .await?
41 .ok_or(CiError::NotFound)?;
42 let artifact_id = Uuid::now_v7();
43 let key = Self::artifact_object_key(job.run_id, job_id, artifact_id);
44 let now = OffsetDateTime::now_utc();
45 let expires_at =
46 now + time::Duration::days(self.config().ci.artifact_retention_days.max(1));
47 workflow_artifact::ActiveModel {
48 id: Set(artifact_id),
49 run_id: Set(job.run_id),
50 job_id: Set(job_id),
51 name: Set(name.to_string()),
52 object_key: Set(key.clone()),
53 size: Set(size),
54 sha256: Set(sha256),
55 mime_type: Set(None),
56 state: Set(ArtifactState::Uploading),
57 expires_at: Set(Some(expires_at)),
58 created_at: Set(now),
59 updated_at: Set(now),
60 }
61 .insert(self.db_write())
62 .await?;
63 let url = self
64 .storage()
65 .client()
66 .presign_put(&key, 3600)
67 .map_err(CiError::from)?;
68 Ok((artifact_id, url))
69 }
70
71 pub async fn complete_artifact(
72 &self,
73 job_id: Uuid,
74 runner_id: Uuid,
75 artifact_id: Uuid,
76 size: i64,
77 sha256: Option<String>,
78 ) -> CiResult<()> {
79 self.ensure_job_owned(job_id, runner_id).await?;
80 let row = workflow_artifact::Entity::find_by_id(artifact_id)
81 .one(self.db_read())
82 .await?
83 .ok_or(CiError::NotFound)?;
84 if row.job_id != job_id {
85 return Err(CiError::NotFound);
86 }
87 let exists = self
88 .storage()
89 .client()
90 .exists(&row.object_key)
91 .await
92 .map_err(CiError::from)?;
93 if !exists {
94 return Err(CiError::InvalidArgument(
95 "artifact object was not uploaded".to_string(),
96 ));
97 }
98 let mut am: workflow_artifact::ActiveModel = row.into();
99 am.size = Set(size);
100 if sha256.is_some() {
101 am.sha256 = Set(sha256);
102 }
103 am.state = Set(ArtifactState::Ready);
104 am.updated_at = Set(OffsetDateTime::now_utc());
105 am.update(self.db_write()).await?;
106 Ok(())
107 }
108
109 pub async fn artifact_download_url(&self, artifact_id: Uuid) -> CiResult<String> {
110 let row = workflow_artifact::Entity::find_by_id(artifact_id)
111 .one(self.db_read())
112 .await?
113 .ok_or(CiError::NotFound)?;
114 if row.state != ArtifactState::Ready {
115 return Err(CiError::InvalidArgument(
116 "artifact is not ready".to_string(),
117 ));
118 }
119 self.storage()
120 .client()
121 .presign_get(&row.object_key, 900)
122 .map_err(CiError::from)
123 }
124
125 pub async fn artifacts_for_run(&self, run_id: Uuid) -> CiResult<Vec<workflow_artifact::Model>> {
126 Ok(workflow_artifact::Entity::find()
127 .filter(workflow_artifact::Column::RunId.eq(run_id))
128 .all(self.db_read())
129 .await?)
130 }
131
132 pub async fn needs_artifacts(
133 &self,
134 run_id: Uuid,
135 job_id: Uuid,
136 ) -> CiResult<Vec<ci_protocol::runner::NeedsArtifactDto>> {
137 let job = models::ci::workflow_job::Entity::find_by_id(job_id)
138 .one(self.db_read())
139 .await?
140 .ok_or(CiError::NotFound)?;
141 let needs: Vec<String> = serde_json::from_value(job.needs.clone()).unwrap_or_default();
142 if needs.is_empty() {
143 return Ok(Vec::new());
144 }
145 let jobs = models::ci::workflow_job::Entity::find()
146 .filter(models::ci::workflow_job::Column::RunId.eq(run_id))
147 .all(self.db_read())
148 .await?;
149 let mut out = Vec::new();
150 for need in needs {
151 let Some(need_job) = jobs.iter().find(|j| j.job_key == need) else {
152 continue;
153 };
154 let artifacts = self.artifacts_for_run(run_id).await?;
155 for artifact in artifacts
156 .into_iter()
157 .filter(|a| a.job_id == need_job.id && a.state == ArtifactState::Ready)
158 {
159 let download_url = self
160 .storage()
161 .client()
162 .presign_get(&artifact.object_key, 3600)
163 .map_err(CiError::from)?;
164 out.push(ci_protocol::runner::NeedsArtifactDto {
165 run_id,
166 job_id: need_job.id,
167 name: artifact.name,
168 download_url,
169 size: artifact.size,
170 sha256: artifact.sha256,
171 });
172 }
173 }
174 Ok(out)
175 }
176
177 #[tracing::instrument(name = "ci.prune", skip_all)]
178 pub async fn prune_expired(&self) -> CiResult<u64> {
179 use models::ci::ci_log_chunk;
180 use models::ci::workflow_artifact as artifact_entity;
181
182 let now = OffsetDateTime::now_utc();
183 let mut removed = 0u64;
184
185 let log_cutoff = now - time::Duration::days(self.config().ci.log_retention_days.max(0));
186 let logs = ci_log_chunk::Entity::find()
187 .filter(ci_log_chunk::Column::CreatedAt.lt(log_cutoff))
188 .limit(500)
189 .all(self.db_read())
190 .await?;
191 for chunk in logs {
192 let _ = self.storage().client().delete(&chunk.object_key).await;
193 let _ = ci_log_chunk::Entity::delete_by_id(chunk.id)
194 .exec(self.db_write())
195 .await;
196 removed += 1;
197 }
198
199 let artifact_cutoff =
200 now - time::Duration::days(self.config().ci.artifact_retention_days.max(0));
201 let artifacts = artifact_entity::Entity::find()
202 .filter(artifact_entity::Column::CreatedAt.lt(artifact_cutoff))
203 .limit(500)
204 .all(self.db_read())
205 .await?;
206 for artifact in artifacts {
207 let _ = self.storage().client().delete(&artifact.object_key).await;
208 let _ = artifact_entity::Entity::delete_by_id(artifact.id)
209 .exec(self.db_write())
210 .await;
211 removed += 1;
212 }
213
214 let run_cutoff = now - time::Duration::days(self.config().ci.run_retention_days.max(0));
215 let runs = workflow_run::Entity::find()
216 .filter(workflow_run::Column::CreatedAt.lt(run_cutoff))
217 .limit(200)
218 .all(self.db_read())
219 .await?;
220 for run in runs {
221 let job_ids: Vec<Uuid> = models::ci::workflow_job::Entity::find()
222 .filter(models::ci::workflow_job::Column::RunId.eq(run.id))
223 .all(self.db_read())
224 .await
225 .unwrap_or_default()
226 .into_iter()
227 .map(|j| j.id)
228 .collect();
229 for job_id in &job_ids {
230 let _ = models::ci::workflow_step::Entity::delete_many()
231 .filter(models::ci::workflow_step::Column::JobId.eq(*job_id))
232 .exec(self.db_write())
233 .await;
234 let _ = models::ci::ci_log_chunk::Entity::delete_many()
235 .filter(models::ci::ci_log_chunk::Column::JobId.eq(*job_id))
236 .exec(self.db_write())
237 .await;
238 }
239 let _ = models::ci::workflow_job::Entity::delete_many()
240 .filter(models::ci::workflow_job::Column::RunId.eq(run.id))
241 .exec(self.db_write())
242 .await;
243 let _ = models::ci::ci_deployment::Entity::delete_many()
244 .filter(models::ci::ci_deployment::Column::RunId.eq(run.id))
245 .exec(self.db_write())
246 .await;
247 let _ = workflow_run::Entity::delete_by_id(run.id)
248 .exec(self.db_write())
249 .await;
250 removed += 1;
251 }
252 Ok(removed)
253 }
254}
Deleted lib/ci/src/triggers.rs
@@ -1,449 +0,0 @@
1use models::ci::workflow_run::{self, WorkflowEventKind, WorkflowRunState};
2use models::ci::workflow_step;
3use models::ci::{actions_identity, workflow_job};
4use models::repo::repos;
5use models::space::spaces;
6use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect};
7use time::OffsetDateTime;
8use uuid::Uuid;
9
10use crate::errors::{CiError, CiResult};
11use crate::runs::RunSeed;
12use crate::service::CiService;
13
14#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
15pub struct RunDto {
16 pub id: Uuid,
17 pub repo_id: Uuid,
18 pub workflow_file: String,
19 pub workflow_name: String,
20 pub display_name: Option<String>,
21 pub actions_id: Option<i64>,
22 pub run_number: Option<i64>,
23 pub event: String,
24 pub commit_sha: String,
25 pub git_ref: String,
26 pub branch: Option<String>,
27 pub tag: Option<String>,
28 pub base_sha: Option<String>,
29 pub base_ref: Option<String>,
30 pub pr_number: Option<i64>,
31 pub state: String,
32 pub trigger_user_id: Option<i64>,
33 pub started_at: Option<OffsetDateTime>,
34 pub finished_at: Option<OffsetDateTime>,
35 pub created_at: OffsetDateTime,
36}
37
38#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
39pub struct StepDto {
40 pub index: i32,
41 pub name: String,
42 pub state: String,
43 pub exit_code: Option<i32>,
44 pub started_at: Option<OffsetDateTime>,
45 pub finished_at: Option<OffsetDateTime>,
46 pub stage: String,
47 pub outcome: Option<String>,
48 pub conclusion: Option<String>,
49 pub outputs: serde_json::Value,
50 pub annotations: serde_json::Value,
51 pub summary: Option<String>,
52}
53
54#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
55pub struct JobDto {
56 pub id: Uuid,
57 pub name: String,
58 pub state: String,
59 pub runs_on: Vec<String>,
60 pub environment: Option<String>,
61 pub needs: Vec<String>,
62 pub runner_id: Option<Uuid>,
63 pub attempt: i32,
64 pub exit_code: Option<i32>,
65 pub error: Option<String>,
66 pub started_at: Option<OffsetDateTime>,
67 pub finished_at: Option<OffsetDateTime>,
68 pub steps: Vec<StepDto>,
69 pub job_key: String,
70 pub is_template: bool,
71 pub logical_job_id: Option<Uuid>,
72 pub matrix: serde_json::Value,
73 pub outputs: serde_json::Value,
74 pub outcome: Option<String>,
75}
76
77#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
78pub struct RunDetailDto {
79 pub run: RunDto,
80 pub jobs: Vec<JobDto>,
81 pub artifacts: Vec<ArtifactDto>,
82}
83
84#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
85pub struct ArtifactDto {
86 pub id: Uuid,
87 pub actions_id: Option<i64>,
88 pub job_id: Uuid,
89 pub name: String,
90 pub size: i64,
91 pub sha256: Option<String>,
92 pub state: String,
93 pub created_at: OffsetDateTime,
94}
95
96#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
97pub struct ListRunsQuery {
98 pub workflow: Option<String>,
99 pub branch: Option<String>,
100 pub commit: Option<String>,
101 pub state: Option<WorkflowRunState>,
102 pub event: Option<String>,
103 pub limit: Option<u64>,
104 pub offset: Option<u64>,
105}
106
107#[derive(Debug, Clone, serde::Deserialize, serde::Serialize, utoipa::ToSchema)]
108#[serde(untagged)]
109pub enum TriggerInputValue {
110 Boolean(bool),
111 Number(f64),
112 String(String),
113}
114
115impl From<TriggerInputValue> for serde_json::Value {
116 fn from(value: TriggerInputValue) -> Self {
117 match value {
118 TriggerInputValue::Boolean(value) => serde_json::json!(value),
119 TriggerInputValue::Number(value) => serde_json::json!(value),
120 TriggerInputValue::String(value) => serde_json::json!(value),
121 }
122 }
123}
124
125#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)]
126pub struct TriggerRunCmd {
127 pub workflow: String,
128 pub git_ref: String,
129 #[serde(default)]
130 pub inputs: std::collections::BTreeMap<String, TriggerInputValue>,
131}
132
133#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
134pub struct RunPageDto {
135 pub runs: Vec<RunDto>,
136 pub total: u64,
137 pub limit: u64,
138 pub offset: u64,
139}
140
141pub fn run_state_str(state: WorkflowRunState) -> String {
142 crate::share::states::run_state_str(state).to_string()
143}
144
145pub fn job_state_str(state: workflow_job::WorkflowJobState) -> String {
146 state.as_str().to_string()
147}
148
149pub fn step_state_str(state: workflow_step::WorkflowStepState) -> String {
150 use workflow_step::WorkflowStepState as S;
151 match state {
152 S::Pending => "pending",
153 S::Running => "running",
154 S::Success => "success",
155 S::Failure => "failure",
156 S::Skipped => "skipped",
157 S::Cancelled => "cancelled",
158 }
159 .to_string()
160}
161
162impl From<workflow_run::Model> for RunDto {
163 fn from(m: workflow_run::Model) -> Self {
164 Self {
165 id: m.id,
166 repo_id: m.repo_id,
167 workflow_file: m.workflow_file,
168 workflow_name: m.workflow_name,
169 display_name: m.definition["_display_name"].as_str().map(str::to_owned),
170 actions_id: m.actions_id,
171 run_number: m.run_number,
172 event: m.event.as_str().to_string(),
173 commit_sha: m.commit_sha,
174 git_ref: m.git_ref,
175 branch: m.branch,
176 tag: m.tag,
177 base_sha: m.base_sha,
178 base_ref: m.base_ref,
179 pr_number: m.pr_number,
180 state: run_state_str(m.state),
181 trigger_user_id: m.trigger_user_id,
182 started_at: m.started_at,
183 finished_at: m.finished_at,
184 created_at: m.created_at,
185 }
186 }
187}
188
189impl CiService {
190 pub async fn list_runs(
191 &self,
192 repo: &repos::Model,
193 query: &ListRunsQuery,
194 ) -> CiResult<RunPageDto> {
195 let limit = query.limit.unwrap_or(50).clamp(1, 100);
196 let offset = query.offset.unwrap_or(0).min(50_000);
197 let mut q = workflow_run::Entity::find()
198 .filter(workflow_run::Column::RepoId.eq(repo.id))
199 .order_by_desc(workflow_run::Column::CreatedAt);
200 if let Some(workflow) = query.workflow.clone().filter(|w| !w.is_empty()) {
201 q = q.filter(workflow_run::Column::WorkflowFile.eq(workflow));
202 }
203 if let Some(branch) = query.branch.clone().filter(|b| !b.is_empty()) {
204 q = q.filter(workflow_run::Column::Branch.eq(branch));
205 }
206 if let Some(commit) = query.commit.clone().filter(|c| !c.is_empty()) {
207 q = q.filter(workflow_run::Column::CommitSha.eq(commit));
208 }
209 if let Some(state) = query.state {
210 q = q.filter(workflow_run::Column::State.eq(state));
211 }
212 if let Some(event) = query.event.as_deref() {
213 let kind = match event {
214 "push" => Some(WorkflowEventKind::Push),
215 "pull_request" => Some(WorkflowEventKind::PullRequest),
216 "workflow_dispatch" => Some(WorkflowEventKind::WorkflowDispatch),
217 _ => None,
218 };
219 if let Some(kind) = kind {
220 q = q.filter(workflow_run::Column::Event.eq(kind));
221 }
222 }
223 let total = q.clone().count(self.db_read()).await?;
224 let rows = q.limit(limit).offset(offset).all(self.db_read()).await?;
225 Ok(RunPageDto {
226 runs: rows.into_iter().map(RunDto::from).collect(),
227 total,
228 limit,
229 offset,
230 })
231 }
232
233 pub async fn run_detail(&self, run_id: Uuid) -> CiResult<RunDetailDto> {
234 let run = workflow_run::Entity::find_by_id(run_id)
235 .one(self.db_read())
236 .await?
237 .ok_or(CiError::NotFound)?;
238 let jobs = workflow_job::Entity::find()
239 .filter(workflow_job::Column::RunId.eq(run_id))
240 .order_by_asc(workflow_job::Column::CreatedAt)
241 .all(self.db_read())
242 .await?;
243 let mut job_dtos = Vec::with_capacity(jobs.len());
244 for job in &jobs {
245 let steps = workflow_step::Entity::find()
246 .filter(workflow_step::Column::JobId.eq(job.id))
247 .order_by_asc(workflow_step::Column::StepIndex)
248 .all(self.db_read())
249 .await?;
250 job_dtos.push(JobDto {
251 id: job.id,
252 name: job.name.clone(),
253 job_key: job.job_key.clone(),
254 is_template: job.is_template,
255 logical_job_id: job.logical_job_id,
256 matrix: job
257 .actions_context
258 .as_ref()
259 .and_then(|ctx| ctx["instance"].get("matrix"))
260 .cloned()
261 .unwrap_or(serde_json::json!({})),
262 outputs: job.outputs.clone().unwrap_or(serde_json::json!({})),
263 outcome: job.outcome.clone(),
264 state: job_state_str(job.state),
265 runs_on: serde_json::from_value(job.runs_on.clone()).unwrap_or_default(),
266 environment: job.environment.clone(),
267 needs: serde_json::from_value(job.needs.clone()).unwrap_or_default(),
268 runner_id: job.runner_id,
269 attempt: job.attempt,
270 exit_code: job.exit_code,
271 error: job.error.clone(),
272 started_at: job.started_at,
273 finished_at: job.finished_at,
274 steps: steps
275 .into_iter()
276 .map(|step| StepDto {
277 index: step.step_index,
278 name: step.name,
279 state: step_state_str(step.state),
280 exit_code: step.exit_code,
281 started_at: step.started_at,
282 finished_at: step.finished_at,
283 stage: step.stage.unwrap_or_else(|| "main".into()),
284 outcome: step.outcome,
285 conclusion: step.conclusion,
286 outputs: step.outputs.unwrap_or(serde_json::json!({})),
287 annotations: step.annotations.unwrap_or(serde_json::json!([])),
288 summary: step.summary,
289 })
290 .collect(),
291 });
292 }
293 let artifacts = self.artifacts_for_run(run_id).await?;
294 let artifact_ids: Vec<Uuid> = artifacts.iter().map(|artifact| artifact.id).collect();
295 let identities = actions_identity::Entity::find()
296 .filter(actions_identity::Column::EntityId.is_in(artifact_ids))
297 .all(self.db_read())
298 .await?;
299 let identity_by_entity: std::collections::BTreeMap<Uuid, i64> = identities
300 .into_iter()
301 .map(|identity| (identity.entity_id, identity.id))
302 .collect();
303 let artifacts = artifacts
304 .into_iter()
305 .map(|a| ArtifactDto {
306 id: a.id,
307 actions_id: identity_by_entity.get(&a.id).copied(),
308 job_id: a.job_id,
309 name: a.name,
310 size: a.size,
311 sha256: a.sha256,
312 state: match a.state {
313 models::ci::workflow_artifact::ArtifactState::Pending => "pending",
314 models::ci::workflow_artifact::ArtifactState::Uploading => "uploading",
315 models::ci::workflow_artifact::ArtifactState::Ready => "ready",
316 models::ci::workflow_artifact::ArtifactState::Failed => "failed",
317 }
318 .to_string(),
319 created_at: a.created_at,
320 })
321 .collect();
322 Ok(RunDetailDto {
323 run: RunDto::from(run),
324 jobs: job_dtos,
325 artifacts,
326 })
327 }
328
329 /// Manual `workflow_dispatch` trigger.
330 pub async fn trigger_run(
331 &self,
332 space: &spaces::Model,
333 repo: &repos::Model,
334 caller: i64,
335 cmd: &TriggerRunCmd,
336 ) -> CiResult<Uuid> {
337 self.ensure_enabled()?;
338 let git = git::service::GitService::new(self.state.clone());
339 let sha = git
340 .rev_parse(
341 space.lower_name.clone(),
342 repo.name.clone(),
343 cmd.git_ref.clone(),
344 )
345 .await
346 .map_err(CiError::from)?;
347 let workflow = self
348 .load_workflow_by_file(space, &repo.name, &sha, &cmd.workflow)
349 .await?;
350 if workflow.definition.on.workflow_dispatch.is_none() {
351 return Err(CiError::InvalidArgument(
352 "workflow does not declare `workflow_dispatch`".to_string(),
353 ));
354 }
355 let provided = cmd
356 .inputs
357 .iter()
358 .map(|(name, value)| (name.clone(), value.clone().into()))
359 .collect::<std::collections::BTreeMap<_, _>>();
360 let inputs = validate_dispatch_inputs(
361 workflow
362 .actions
363 .as_ref()
364 .ok_or_else(|| CiError::InvalidArgument("Actions definition missing".into()))?,
365 &provided,
366 )?;
367 let (branch, tag) = classify_ref(&cmd.git_ref);
368 let seed = RunSeed {
369 event: WorkflowEventKind::WorkflowDispatch,
370 event_payload: serde_json::json!({
371 "ref": cmd.git_ref,
372 "sha": sha,
373 "actor_id": caller,
374 "inputs": inputs.as_object().map(|values|values.iter().map(|(name,value)|(name.clone(),value.as_str().map(str::to_owned).unwrap_or_else(||value.to_string()))).collect::<std::collections::BTreeMap<_,_>>()).unwrap_or_default(),
375 "_gitdata_inputs": inputs,
376 "event_id": Uuid::now_v7(),
377 }),
378 commit_sha: sha,
379 git_ref: cmd.git_ref.clone(),
380 branch,
381 tag,
382 base_sha: None,
383 base_ref: None,
384 pr_number: None,
385 trigger_user_id: Some(caller),
386 };
387 self.create_run(repo, space, &seed, &workflow).await
388 }
389}
390
391fn validate_dispatch_inputs(
392 workflow: &ci_protocol::actions::ActionsWorkflow,
393 provided: &std::collections::BTreeMap<String, serde_json::Value>,
394) -> CiResult<serde_json::Value> {
395 let definitions = workflow.inputs();
396 let mut inputs = serde_json::Map::new();
397 for name in provided.keys() {
398 if !definitions.contains_key(name) {
399 return Err(CiError::InvalidArgument(format!(
400 "unknown workflow_dispatch input `{name}`"
401 )));
402 }
403 }
404 for (name, definition) in definitions {
405 let value = provided.get(&name).cloned().or(definition.default.clone());
406 if definition.required
407 && value.as_ref().is_none_or(|value| {
408 value.is_null() || (value.is_string() && value.as_str() == Some(""))
409 })
410 {
411 return Err(CiError::InvalidArgument(format!(
412 "required input `{name}` is missing"
413 )));
414 }
415 let value = value.unwrap_or_else(|| match definition.kind.as_deref() {
416 Some("boolean") => serde_json::json!(false),
417 Some("number") => serde_json::json!(0),
418 _ => serde_json::json!(""),
419 });
420 let valid = match definition.kind.as_deref() {
421 Some("boolean") => value.is_boolean(),
422 Some("number") => value.is_number(),
423 Some("choice") => value
424 .as_str()
425 .is_some_and(|value| definition.options.iter().any(|option| option == value)),
426 _ => value.is_string(),
427 };
428 if !valid {
429 return Err(CiError::InvalidArgument(format!(
430 "input `{name}` has the wrong type or choice"
431 )));
432 }
433 inputs.insert(name, value);
434 }
435 Ok(serde_json::Value::Object(inputs))
436}
437
438#[must_use]
439pub fn classify_ref(git_ref: &str) -> (Option<String>, Option<String>) {
440 if let Some(tag) = git_ref.strip_prefix("refs/tags/") {
441 (None, Some(tag.to_string()))
442 } else if let Some(branch) = git_ref.strip_prefix("refs/heads/") {
443 (Some(branch.to_string()), None)
444 } else if git_ref.starts_with("refs/") || git_ref.contains('/') {
445 (None, None)
446 } else {
447 (Some(git_ref.to_string()), None)
448 }
449}
Deleted lib/ci/src/workflows.rs
@@ -1,352 +0,0 @@
1use ci_protocol::actions::{ACTIONS_WORKFLOW_DIR, ActionsWorkflow};
2use ci_protocol::dsl::{
3 PullRequestTrigger, PushTrigger, TriggerSummary, WORKFLOW_DIR, WorkflowDefinition,
4 WorkflowSummary,
5};
6use git::service::GitService;
7use models::space::spaces;
8
9use crate::errors::{CiError, CiResult};
10use crate::service::CiService;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct LoadedWorkflow {
14 pub file: String,
15 pub name: String,
16 pub definition: WorkflowDefinition,
17 pub actions: Option<ActionsWorkflow>,
18}
19
20const MAX_WORKFLOW_BYTES: u64 = 256 * 1024;
21
22impl CiService {
23 fn git_service(&self) -> GitService {
24 GitService::new(self.state.clone())
25 }
26
27 pub async fn list_workflows(
28 &self,
29 space: &spaces::Model,
30 repo_name: &str,
31 revision: &str,
32 ) -> CiResult<Vec<WorkflowSummary>> {
33 let entries = self.workflow_entries(space, repo_name, revision).await?;
34 let mut out = Vec::with_capacity(entries.len());
35 for entry in entries {
36 let source = match self
37 .read_workflow_file(space, repo_name, revision, &entry)
38 .await
39 {
40 Ok(source) => source,
41 Err(error) => {
42 out.push(WorkflowSummary {
43 file: entry.clone(),
44 name: entry.clone(),
45 valid: false,
46 errors: vec![error.to_string()],
47 triggers: TriggerSummary::default(),
48 inputs: Default::default(),
49 engine: "actions".into(),
50 });
51 continue;
52 }
53 };
54 match self.parse_loaded_workflow(&entry, &source).await {
55 Ok(workflow) => out.push(WorkflowSummary {
56 file: entry.clone(),
57 name: workflow.name,
58 valid: true,
59 errors: Vec::new(),
60 triggers: TriggerSummary::from_triggers(&workflow.definition.on),
61 inputs: workflow
62 .actions
63 .as_ref()
64 .map(ActionsWorkflow::inputs)
65 .unwrap_or_default(),
66 engine: "actions".into(),
67 }),
68 Err(error) => out.push(WorkflowSummary {
69 file: entry.clone(),
70 name: file_stem(&entry),
71 valid: false,
72 errors: vec![error.to_string()],
73 triggers: TriggerSummary::default(),
74 inputs: Default::default(),
75 engine: "actions".into(),
76 }),
77 }
78 }
79 Ok(out)
80 }
81
82 pub async fn load_valid_workflows(
83 &self,
84 space: &spaces::Model,
85 repo_name: &str,
86 revision: &str,
87 ) -> CiResult<Vec<LoadedWorkflow>> {
88 let entries = self.workflow_entries(space, repo_name, revision).await?;
89 let mut out = Vec::new();
90 for entry in entries {
91 let source = match self
92 .read_workflow_file(space, repo_name, revision, &entry)
93 .await
94 {
95 Ok(source) => source,
96 Err(error) => {
97 tracing::warn!(file = %entry, error = %error, "workflow read failed; skipping");
98 continue;
99 }
100 };
101 match self.parse_loaded_workflow(&entry, &source).await {
102 Ok(workflow) => out.push(workflow),
103 Err(error) => {
104 tracing::warn!(file = %entry, error = %error, "workflow parse failed; skipping");
105 }
106 }
107 }
108 Ok(out)
109 }
110
111 pub async fn load_workflow_by_file(
112 &self,
113 space: &spaces::Model,
114 repo_name: &str,
115 revision: &str,
116 file: &str,
117 ) -> CiResult<LoadedWorkflow> {
118 let mut normalized = normalize_workflow_file(file)?;
119 if !file.trim().starts_with(&format!("{WORKFLOW_DIR}/"))
120 && !normalized.starts_with(ACTIONS_WORKFLOW_DIR)
121 {
122 let candidate = format!("{ACTIONS_WORKFLOW_DIR}/{normalized}");
123 if self
124 .workflow_entries(space, repo_name, revision)
125 .await?
126 .contains(&candidate)
127 {
128 normalized = candidate;
129 }
130 }
131 let source = self
132 .read_workflow_file(space, repo_name, revision, &normalized)
133 .await?;
134 self.parse_loaded_workflow(&normalized, &source).await
135 }
136
137 async fn parse_loaded_workflow(&self, file: &str, source: &str) -> CiResult<LoadedWorkflow> {
138 let actions = Some(self.parse_actions_workflow(file, source).await?);
139 let definition = actions.as_ref().unwrap().projection();
140 Ok(LoadedWorkflow {
141 file: file.to_owned(),
142 name: definition.name.clone().unwrap_or_else(|| file_stem(file)),
143 definition,
144 actions,
145 })
146 }
147
148 async fn workflow_entries(
149 &self,
150 space: &spaces::Model,
151 repo_name: &str,
152 revision: &str,
153 ) -> CiResult<Vec<String>> {
154 let service = self.git_service();
155 let mut selected = std::collections::BTreeMap::new();
156 for directory in [ACTIONS_WORKFLOW_DIR] {
157 let entries = service
158 .tree_read(
159 space.lower_name.clone(),
160 repo_name.to_string(),
161 revision.to_string(),
162 std::path::PathBuf::from(directory),
163 )
164 .await;
165 let entries = match entries {
166 Ok(entries) => entries,
167 Err(git::share::errors::GitError::NotFound) => continue,
168 Err(error) => return Err(error.into()),
169 };
170 let prefix = format!("{directory}/");
171 let mut out: Vec<String> = entries
172 .into_iter()
173 .filter(|entry| {
174 entry.mode == git::rpcgit::commit::types::TreeEntryMode::Blob
175 || entry.mode == git::rpcgit::commit::types::TreeEntryMode::Exec
176 })
177 .map(|entry| {
178 entry
179 .name
180 .strip_prefix(prefix.as_str())
181 .unwrap_or(entry.name.as_str())
182 .to_string()
183 })
184 .filter(|name| {
185 std::path::Path::new(name).extension().is_some_and(|ext| {
186 ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml")
187 })
188 })
189 .collect();
190 out.sort();
191 out.dedup();
192 for name in out {
193 selected.insert(name.clone(), format!("{directory}/{name}"));
194 }
195 }
196 Ok(selected.into_values().collect())
197 }
198
199 async fn read_workflow_file(
200 &self,
201 space: &spaces::Model,
202 repo_name: &str,
203 revision: &str,
204 file: &str,
205 ) -> CiResult<String> {
206 let service = self.git_service();
207 let path = if file.starts_with(&format!("{ACTIONS_WORKFLOW_DIR}/")) {
208 file.to_owned()
209 } else {
210 format!("{WORKFLOW_DIR}/{file}")
211 };
212 let entry = service
213 .tree_resolve_path(
214 space.lower_name.clone(),
215 repo_name.to_string(),
216 revision.to_string(),
217 path.clone(),
218 )
219 .await
220 .map_err(CiError::from)?;
221 if entry.mode != git::rpcgit::commit::types::TreeEntryMode::Blob
222 && entry.mode != git::rpcgit::commit::types::TreeEntryMode::Exec
223 {
224 return Err(CiError::InvalidArgument(format!(
225 "`{path}` is not a regular file"
226 )));
227 }
228 let blob = service
229 .blob_content(
230 space.lower_name.clone(),
231 repo_name.to_string(),
232 entry.oid.to_string(),
233 )
234 .await
235 .map_err(CiError::from)?;
236 if blob.truncated || blob.size as u64 > MAX_WORKFLOW_BYTES {
237 return Err(CiError::InvalidArgument(format!(
238 "`{path}` exceeds the {MAX_WORKFLOW_BYTES} byte workflow limit"
239 )));
240 }
241 String::from_utf8(blob.data)
242 .map_err(|_| CiError::InvalidArgument(format!("`{path}` is not valid UTF-8")))
243 }
244}
245
246pub fn normalize_workflow_file(file: &str) -> CiResult<String> {
247 let trimmed = file.trim();
248 let actions = trimmed.starts_with(&format!("{ACTIONS_WORKFLOW_DIR}/"));
249 let name = trimmed
250 .strip_prefix(&format!("{ACTIONS_WORKFLOW_DIR}/"))
251 .or_else(|| trimmed.strip_prefix(&format!("{WORKFLOW_DIR}/")))
252 .unwrap_or(trimmed);
253 if name.is_empty()
254 || name.contains('/')
255 || name.contains('\\')
256 || name.contains("..")
257 || name.len() > 200
258 {
259 return Err(CiError::InvalidArgument(
260 "workflow file must be under .github/workflows/".to_string(),
261 ));
262 }
263 let ext_ok = std::path::Path::new(name)
264 .extension()
265 .is_some_and(|ext| ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml"));
266 if !ext_ok {
267 return Err(CiError::InvalidArgument(
268 "workflow file must end with .yml or .yaml".to_string(),
269 ));
270 }
271 Ok(if actions {
272 format!("{ACTIONS_WORKFLOW_DIR}/{name}")
273 } else {
274 name.to_string()
275 })
276}
277
278#[must_use]
279pub fn file_stem(file: &str) -> String {
280 std::path::Path::new(file)
281 .file_stem()
282 .map(|s| s.to_string_lossy().into_owned())
283 .unwrap_or_else(|| file.to_string())
284}
285
286/// Returns whether a push to `branch`/`tag` (or neither) should trigger.
287#[must_use]
288pub fn push_trigger_matches(
289 trigger: &PushTrigger,
290 branch: Option<&str>,
291 tag: Option<&str>,
292) -> bool {
293 match (branch, tag) {
294 (Some(branch), None) => {
295 trigger.tags.is_empty() && ci_protocol::dsl::matches_patterns(&trigger.branches, branch)
296 }
297 (None, Some(tag)) => {
298 trigger.branches.is_empty() && ci_protocol::dsl::matches_patterns(&trigger.tags, tag)
299 }
300 (None, None) => false,
301 (Some(branch), Some(_)) => {
302 ci_protocol::dsl::matches_patterns(&trigger.branches, branch)
303 && ci_protocol::dsl::matches_patterns(&trigger.tags, tag.unwrap_or_default())
304 }
305 }
306}
307
308#[must_use]
309pub fn pull_request_action(
310 action: ci_protocol::events::PullRequestAction,
311) -> ci_protocol::dsl::PullRequestAction {
312 match action {
313 ci_protocol::events::PullRequestAction::Opened => {
314 ci_protocol::dsl::PullRequestAction::Opened
315 }
316 ci_protocol::events::PullRequestAction::Synchronize => {
317 ci_protocol::dsl::PullRequestAction::Synchronize
318 }
319 ci_protocol::events::PullRequestAction::Reopened => {
320 ci_protocol::dsl::PullRequestAction::Reopened
321 }
322 ci_protocol::events::PullRequestAction::Merged => {
323 ci_protocol::dsl::PullRequestAction::Merged
324 }
325 ci_protocol::events::PullRequestAction::Closed => {
326 ci_protocol::dsl::PullRequestAction::Closed
327 }
328 }
329}
330
331const DEFAULT_PR_ACTIONS: [ci_protocol::dsl::PullRequestAction; 3] = [
332 ci_protocol::dsl::PullRequestAction::Opened,
333 ci_protocol::dsl::PullRequestAction::Synchronize,
334 ci_protocol::dsl::PullRequestAction::Reopened,
335];
336
337#[must_use]
338pub fn pull_request_trigger_matches(
339 trigger: &PullRequestTrigger,
340 base_ref: &str,
341 action: ci_protocol::events::PullRequestAction,
342) -> bool {
343 let branch_ok = trigger.branches.is_empty()
344 || ci_protocol::dsl::matches_patterns(&trigger.branches, base_ref);
345 let wanted = pull_request_action(action);
346 let action_ok = if trigger.types.is_empty() {
347 DEFAULT_PR_ACTIONS.contains(&wanted)
348 } else {
349 trigger.types.contains(&wanted)
350 };
351 branch_ok && action_ok
352}
Deleted lib/ci/Cargo.toml
@@ -1,38 +0,0 @@
1[package]
2name = "ci"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7ci-protocol = { workspace = true }
8config = { workspace = true }
9export = { workspace = true }
10git = { workspace = true }
11models = { workspace = true }
12jwt = { workspace = true }
13space = { workspace = true }
14storage = { workspace = true }
15axum = { workspace = true }
16sea-orm = { workspace = true }
17serde = { workspace = true, features = ["derive"] }
18serde_json = { workspace = true }
19serde_yaml_ng = { workspace = true }
20utoipa = { workspace = true }
21reqwest = { workspace = true }
22tokio = { workspace = true, features = ["fs", "process", "time", "sync", "rt", "macros"] }
23tracing = { workspace = true }
24globset = { workspace = true }
25hmac = { workspace = true }
26sha2 = { workspace = true }
27base64 = { workspace = true }
28aes-gcm = { workspace = true }
29rand = { workspace = true }
30uuid = { workspace = true }
31time = { workspace = true }
32thiserror = { workspace = true }
33async-trait = { workspace = true }
34futures = { workspace = true }
35
36[dev-dependencies]
37runner = { path = "../../app/runner" }
38anyhow = { workspace = true }
Deleted lib/ci-protocol/src/actions.rs
@@ -1,236 +0,0 @@
1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use serde_json::{Value, json};
5
6use crate::dsl::{Concurrency, Job, MarkedPermissions, Step, WorkflowDefinition, WorkflowTriggers};
7
8pub const ACTIONS_WORKFLOW_DIR: &str = ".github/workflows";
9pub const SNAPSHOT_KEY: &str = "_gitdata_actions";
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
12pub struct ActionsWorkflow {
13 pub file: String,
14 pub source: String,
15 pub document: Value,
16}
17
18#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
19pub struct InputDefinition {
20 #[serde(default)]
21 pub description: Option<String>,
22 #[serde(default)]
23 pub required: bool,
24 #[serde(default, rename = "type")]
25 pub kind: Option<String>,
26 #[serde(default)]
27 pub default: Option<Value>,
28 #[serde(default)]
29 pub options: Vec<String>,
30}
31
32#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
33pub struct NeedResult {
34 pub result: String,
35 #[serde(default)]
36 pub outputs: BTreeMap<String, String>,
37}
38
39#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
40pub struct EngineInstance {
41 pub index: usize,
42 pub matrix: Value,
43 pub name: String,
44 pub runs_on: Vec<String>,
45 pub environment: String,
46 pub timeout_seconds: u64,
47 pub continue_on_error: bool,
48 pub fail_fast: bool,
49 pub max_parallel: usize,
50 pub concurrency: Value,
51 pub permissions: Value,
52 #[serde(rename = "with")]
53 pub inputs: Value,
54 pub uses: String,
55 pub group: Option<String>,
56}
57
58#[derive(Debug, Clone, Default, Serialize, Deserialize)]
59pub struct EngineExpansion {
60 pub skipped: bool,
61 pub instances: Vec<EngineInstance>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
65pub struct ActionsExecution {
66 pub workflow: ActionsWorkflow,
67 pub job: String,
68 pub matrix: Value,
69 pub github: Value,
70 pub event: Value,
71 pub needs: BTreeMap<String, NeedResult>,
72 pub inputs: BTreeMap<String, Value>,
73 pub vars: BTreeMap<String, String>,
74 pub token: String,
75 pub runtime_url: String,
76 pub runtime_token: String,
77}
78
79#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
80pub struct EngineEvent {
81 pub event: String,
82 #[serde(default)]
83 pub data: Value,
84 #[serde(default)]
85 pub error: Option<String>,
86 #[serde(default)]
87 pub lease_token: Option<String>,
88}
89
90impl ActionsWorkflow {
91 pub fn name(&self) -> Option<String> {
92 self.document
93 .get("name")
94 .and_then(Value::as_str)
95 .map(str::to_owned)
96 }
97
98 pub fn triggers(&self) -> &Value {
99 self.document.get("on").unwrap_or(&Value::Null)
100 }
101
102 pub fn inputs(&self) -> BTreeMap<String, InputDefinition> {
103 self.triggers()
104 .get("workflow_dispatch")
105 .and_then(|v| v.get("inputs"))
106 .and_then(|v| serde_json::from_value(v.clone()).ok())
107 .unwrap_or_default()
108 }
109
110 pub fn projection(&self) -> WorkflowDefinition {
111 let on = self.triggers();
112 let triggers = WorkflowTriggers {
113 push: on.get("push").map(|value| crate::dsl::PushTrigger {
114 branches: strings(value.get("branches")),
115 tags: strings(value.get("tags")),
116 }),
117 pull_request: on
118 .get("pull_request")
119 .map(|value| crate::dsl::PullRequestTrigger {
120 branches: strings(value.get("branches")),
121 types: strings(value.get("types"))
122 .into_iter()
123 .filter_map(|s| serde_json::from_value(json!(s)).ok())
124 .collect(),
125 }),
126 workflow_dispatch: on
127 .get("workflow_dispatch")
128 .map(|_| crate::dsl::WorkflowDispatchTrigger::default()),
129 };
130 let jobs = self
131 .document
132 .get("jobs")
133 .and_then(Value::as_object)
134 .into_iter()
135 .flatten()
136 .map(|(key, value)| {
137 (
138 key.clone(),
139 Job {
140 runs_on: strings(value.get("runs-on")),
141 condition: None,
142 needs: strings(value.get("needs")),
143 environment: value
144 .get("environment")
145 .and_then(|v| {
146 v.as_str().or_else(|| v.get("name").and_then(Value::as_str))
147 })
148 .map(str::to_owned),
149 permissions: MarkedPermissions::default(),
150 container: None,
151 timeout_minutes: value.get("timeout-minutes").and_then(Value::as_u64),
152 env: env_map(value.get("env")),
153 artifacts: Vec::new(),
154 steps: value
155 .get("steps")
156 .and_then(Value::as_array)
157 .into_iter()
158 .flatten()
159 .map(|step| Step {
160 name: step.get("name").and_then(Value::as_str).map(str::to_owned),
161 run: step
162 .get("run")
163 .or_else(|| step.get("uses"))
164 .and_then(Value::as_str)
165 .unwrap_or_default()
166 .to_owned(),
167 shell: step.get("shell").and_then(Value::as_str).map(str::to_owned),
168 working_directory: step
169 .get("working-directory")
170 .and_then(Value::as_str)
171 .map(str::to_owned),
172 env: env_map(step.get("env")),
173 timeout_minutes: step
174 .get("timeout-minutes")
175 .and_then(Value::as_u64),
176 })
177 .collect(),
178 },
179 )
180 })
181 .collect();
182 WorkflowDefinition {
183 name: self.name(),
184 on: triggers,
185 env: env_map(self.document.get("env")),
186 concurrency: self
187 .document
188 .get("concurrency")
189 .and_then(|value| {
190 value
191 .as_str()
192 .or_else(|| value.get("group").and_then(Value::as_str))
193 })
194 .map(|group| Concurrency {
195 group: group.to_owned(),
196 cancel_in_progress: self.document["concurrency"]["cancel-in-progress"]
197 .as_bool()
198 .unwrap_or(false),
199 }),
200 jobs,
201 }
202 }
203}
204
205pub fn strings(value: Option<&Value>) -> Vec<String> {
206 match value {
207 Some(Value::String(value)) => vec![value.clone()],
208 Some(Value::Array(values)) => values
209 .iter()
210 .filter_map(Value::as_str)
211 .map(str::to_owned)
212 .collect(),
213 _ => Vec::new(),
214 }
215}
216
217pub fn env_map(value: Option<&Value>) -> BTreeMap<String, String> {
218 value
219 .and_then(Value::as_object)
220 .into_iter()
221 .flatten()
222 .map(|(name, value)| {
223 (
224 name.clone(),
225 value
226 .as_str()
227 .map(str::to_owned)
228 .unwrap_or_else(|| value.to_string()),
229 )
230 })
231 .collect()
232}
233
234pub fn from_snapshot(value: &Value) -> Option<ActionsWorkflow> {
235 serde_json::from_value(value.get(SNAPSHOT_KEY)?.clone()).ok()
236}
Deleted lib/ci-protocol/src/dsl.rs
@@ -1,254 +0,0 @@
1use std::collections::BTreeMap;
2
3use globset::{Glob, GlobSet, GlobSetBuilder};
4use serde::{Deserialize, Serialize};
5
6pub const WORKFLOW_DIR: &str = ".github/workflows";
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
9#[serde(deny_unknown_fields)]
10pub struct WorkflowDefinition {
11 #[serde(default, skip_serializing_if = "Option::is_none")]
12 pub name: Option<String>,
13 pub on: WorkflowTriggers,
14 #[serde(default)]
15 pub env: BTreeMap<String, String>,
16 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub concurrency: Option<Concurrency>,
18 pub jobs: BTreeMap<String, Job>,
19}
20
21#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
22#[serde(deny_unknown_fields)]
23pub struct WorkflowTriggers {
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub push: Option<PushTrigger>,
26 #[serde(
27 default,
28 rename = "pull_request",
29 skip_serializing_if = "Option::is_none"
30 )]
31 pub pull_request: Option<PullRequestTrigger>,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub workflow_dispatch: Option<WorkflowDispatchTrigger>,
34}
35
36impl WorkflowTriggers {
37 #[must_use]
38 pub fn is_empty(&self) -> bool {
39 self.push.is_none() && self.pull_request.is_none() && self.workflow_dispatch.is_none()
40 }
41}
42
43#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
44#[serde(deny_unknown_fields)]
45pub struct PushTrigger {
46 #[serde(default)]
47 pub branches: Vec<String>,
48 #[serde(default)]
49 pub tags: Vec<String>,
50}
51
52#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
53#[serde(deny_unknown_fields)]
54pub struct PullRequestTrigger {
55 #[serde(default)]
56 pub branches: Vec<String>,
57 #[serde(default)]
58 pub types: Vec<PullRequestAction>,
59}
60
61#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
62#[serde(deny_unknown_fields)]
63pub struct WorkflowDispatchTrigger {
64 #[serde(default)]
65 pub inputs: BTreeMap<String, String>,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, utoipa::ToSchema)]
69#[serde(rename_all = "snake_case")]
70pub enum PullRequestAction {
71 Opened,
72 Synchronize,
73 Reopened,
74 Merged,
75 Closed,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
79#[serde(deny_unknown_fields)]
80pub struct Concurrency {
81 pub group: String,
82 #[serde(default)]
83 pub cancel_in_progress: bool,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
87#[serde(deny_unknown_fields)]
88pub struct Job {
89 #[serde(default = "default_runs_on", rename = "runs-on")]
90 pub runs_on: Vec<String>,
91 #[serde(default, rename = "if", skip_serializing_if = "Option::is_none")]
92 pub condition: Option<String>,
93 #[serde(default)]
94 pub needs: Vec<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub environment: Option<String>,
97 #[serde(default)]
98 pub permissions: MarkedPermissions,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub container: Option<String>,
101 #[serde(
102 default,
103 rename = "timeout-minutes",
104 skip_serializing_if = "Option::is_none"
105 )]
106 pub timeout_minutes: Option<u64>,
107 #[serde(default)]
108 pub env: BTreeMap<String, String>,
109 #[serde(default)]
110 pub artifacts: Vec<ArtifactSpec>,
111 pub steps: Vec<Step>,
112}
113
114fn default_runs_on() -> Vec<String> {
115 vec!["self-hosted".to_string()]
116}
117
118#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
119#[serde(deny_unknown_fields)]
120pub struct MarkedPermissions {
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub contents: Option<PermissionLevel>,
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
126#[serde(rename_all = "snake_case")]
127pub enum PermissionLevel {
128 Read,
129 Write,
130 None,
131}
132
133impl MarkedPermissions {
134 #[must_use]
135 pub fn contents_write(&self) -> bool {
136 matches!(self.contents, Some(PermissionLevel::Write))
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
141#[serde(deny_unknown_fields)]
142pub struct ArtifactSpec {
143 pub name: String,
144 pub path: String,
145 #[serde(default)]
146 pub always: bool,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
150#[serde(deny_unknown_fields)]
151pub struct Step {
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub name: Option<String>,
154 pub run: String,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub shell: Option<String>,
157 #[serde(
158 default,
159 rename = "working-directory",
160 skip_serializing_if = "Option::is_none"
161 )]
162 pub working_directory: Option<String>,
163 #[serde(default)]
164 pub env: BTreeMap<String, String>,
165 #[serde(
166 default,
167 rename = "timeout-minutes",
168 skip_serializing_if = "Option::is_none"
169 )]
170 pub timeout_minutes: Option<u64>,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
174pub struct WorkflowSummary {
175 pub file: String,
176 pub name: String,
177 pub valid: bool,
178 pub errors: Vec<String>,
179 pub triggers: TriggerSummary,
180 #[serde(default)]
181 pub inputs: BTreeMap<String, crate::actions::InputDefinition>,
182 #[serde(default)]
183 pub engine: String,
184}
185
186#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
187pub struct TriggerSummary {
188 pub push_branches: Vec<String>,
189 pub push_tags: Vec<String>,
190 pub pull_request_branches: Vec<String>,
191 pub pull_request_types: Vec<PullRequestAction>,
192 pub workflow_dispatch: bool,
193}
194
195impl TriggerSummary {
196 #[must_use]
197 pub fn from_triggers(triggers: &WorkflowTriggers) -> Self {
198 Self {
199 push_branches: triggers
200 .push
201 .as_ref()
202 .map(|p| p.branches.clone())
203 .unwrap_or_default(),
204 push_tags: triggers
205 .push
206 .as_ref()
207 .map(|p| p.tags.clone())
208 .unwrap_or_default(),
209 pull_request_branches: triggers
210 .pull_request
211 .as_ref()
212 .map(|p| p.branches.clone())
213 .unwrap_or_default(),
214 pull_request_types: triggers
215 .pull_request
216 .as_ref()
217 .map(|p| p.types.clone())
218 .unwrap_or_default(),
219 workflow_dispatch: triggers.workflow_dispatch.is_some(),
220 }
221 }
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum RefKind {
226 Branch,
227 Tag,
228}
229
230pub fn build_glob_set(patterns: &[String]) -> Option<GlobSet> {
231 if patterns.is_empty() {
232 return None;
233 }
234 let mut builder = GlobSetBuilder::new();
235 let mut any = false;
236 for pattern in patterns {
237 if let Ok(glob) = Glob::new(pattern) {
238 builder.add(glob);
239 any = true;
240 }
241 }
242 if !any {
243 return None;
244 }
245 builder.build().ok()
246}
247
248#[must_use]
249pub fn matches_patterns(patterns: &[String], value: &str) -> bool {
250 match build_glob_set(patterns) {
251 Some(set) => set.is_match(value),
252 None => false,
253 }
254}
Deleted lib/ci-protocol/src/events.rs
@@ -1,134 +0,0 @@
1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5pub struct RefUpdate {
6 pub name: String,
7 pub old_oid: String,
8 pub new_oid: String,
9}
10
11impl RefUpdate {
12 #[must_use]
13 pub fn is_delete(&self) -> bool {
14 self.new_oid.is_empty() || self.new_oid.chars().all(|c| c == '0')
15 }
16
17 #[must_use]
18 pub fn ref_kind(&self) -> Option<&'static str> {
19 if self.name.starts_with("refs/tags/") {
20 Some("tag")
21 } else if self.name.starts_with("refs/heads/") {
22 Some("branch")
23 } else {
24 None
25 }
26 }
27
28 #[must_use]
29 pub fn short_name(&self) -> &str {
30 self.name
31 .strip_prefix("refs/heads/")
32 .or_else(|| self.name.strip_prefix("refs/tags/"))
33 .unwrap_or(self.name.as_str())
34 }
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum PullRequestAction {
40 Opened,
41 Synchronize,
42 Reopened,
43 Merged,
44 Closed,
45}
46
47impl PullRequestAction {
48 #[must_use]
49 pub const fn as_str(self) -> &'static str {
50 match self {
51 Self::Opened => "opened",
52 Self::Synchronize => "synchronize",
53 Self::Reopened => "reopened",
54 Self::Merged => "merged",
55 Self::Closed => "closed",
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(tag = "kind", rename_all = "snake_case")]
62pub enum CiEvent {
63 Push(PushEvent),
64 PullRequest(PullRequestEvent),
65 Trigger(TriggerEvent),
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct PushEvent {
70 pub id: Uuid,
71 pub repo_id: Uuid,
72 pub space_id: Uuid,
73 pub actor_id: Option<i64>,
74 pub refs: Vec<RefUpdate>,
75 /// Producing trace context, carried across the queue to webhook consumers.
76 #[serde(default)]
77 pub trace: Option<String>,
78 #[serde(default)]
79 pub request_id: Option<String>,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct PullRequestEvent {
84 pub id: Uuid,
85 pub repo_id: Uuid,
86 pub space_id: Uuid,
87 pub actor_id: Option<i64>,
88 pub number: i64,
89 pub action: PullRequestAction,
90 pub head_sha: String,
91 pub head_ref: String,
92 pub base_sha: String,
93 pub base_ref: String,
94 #[serde(default)]
95 pub head_repo_id: Option<Uuid>,
96 #[serde(default)]
97 pub trace: Option<String>,
98 #[serde(default)]
99 pub request_id: Option<String>,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct TriggerEvent {
104 pub id: Uuid,
105 pub repo_id: Uuid,
106 pub space_id: Uuid,
107 pub actor_id: Option<i64>,
108 pub event: String,
109 pub payload: serde_json::Value,
110 #[serde(default)]
111 pub trace: Option<String>,
112 #[serde(default)]
113 pub request_id: Option<String>,
114}
115
116impl CiEvent {
117 #[must_use]
118 pub fn trace(&self) -> Option<&str> {
119 match self {
120 Self::Push(event) => event.trace.as_deref(),
121 Self::PullRequest(event) => event.trace.as_deref(),
122 Self::Trigger(event) => event.trace.as_deref(),
123 }
124 }
125
126 #[must_use]
127 pub fn request_id(&self) -> Option<&str> {
128 match self {
129 Self::Push(event) => event.request_id.as_deref(),
130 Self::PullRequest(event) => event.request_id.as_deref(),
131 Self::Trigger(event) => event.request_id.as_deref(),
132 }
133 }
134}
Deleted lib/ci-protocol/src/lib.rs
@@ -1,7 +0,0 @@
1pub mod actions;
2pub mod dsl;
3pub mod events;
4pub mod runner;
5
6pub const PROTOCOL_VERSION: u32 = 2;
7pub const LOG_CHUNK_MAX_BYTES: usize = 262_144;
Deleted lib/ci-protocol/src/runner.rs
@@ -1,272 +0,0 @@
1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use uuid::Uuid;
6
7#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
8pub struct RegisterRequest {
9 pub token: String,
10 pub name: String,
11 #[serde(default)]
12 pub labels: Vec<String>,
13 #[serde(default = "default_capacity")]
14 pub capacity: i32,
15 #[serde(default)]
16 pub version: Option<String>,
17 #[serde(default)]
18 pub protocol_versions: Vec<u32>,
19 #[serde(default)]
20 pub capabilities: Vec<String>,
21}
22
23fn default_capacity() -> i32 {
24 1
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
28pub struct RegisterResponse {
29 pub runner_id: Uuid,
30 pub credential: String,
31 pub heartbeat_interval_secs: u64,
32 pub poll_timeout_secs: u64,
33 pub protocol_version: u32,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
37pub struct HeartbeatRequest {
38 #[serde(default)]
39 pub running_job_ids: Vec<Uuid>,
40 #[serde(default)]
41 pub protocol_versions: Vec<u32>,
42 #[serde(default)]
43 pub capabilities: Vec<String>,
44}
45
46#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
47pub struct HeartbeatResponse {
48 #[serde(default)]
49 pub cancel: Vec<Uuid>,
50 pub next_heartbeat_secs: u64,
51}
52
53#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
54pub struct ClaimRequest {
55 #[serde(default)]
56 pub wait_secs: Option<u64>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
60pub struct ClaimResponse {
61 #[serde(default)]
62 pub job: Option<JobPayload>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
66pub struct LeaseDto {
67 pub token: String,
68 pub expires_at: OffsetDateTime,
69 pub heartbeat_interval_secs: u64,
70 pub attempt: i32,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
74pub struct ArtifactRefDto {
75 pub artifact_id: Uuid,
76 pub name: String,
77 pub upload_url: String,
78 pub sha256: Option<String>,
79 pub declared_size: Option<i64>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
83pub struct ArtifactSpecDto {
84 pub name: String,
85 pub path: String,
86 pub always: bool,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
90pub struct NeedsArtifactDto {
91 pub run_id: Uuid,
92 pub job_id: Uuid,
93 pub name: String,
94 pub download_url: String,
95 pub size: i64,
96 pub sha256: Option<String>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
100pub struct StepPayload {
101 pub index: i32,
102 pub name: String,
103 pub run: String,
104 pub shell: Option<String>,
105 pub working_directory: Option<String>,
106 pub env: BTreeMap<String, String>,
107 pub timeout_secs: Option<u64>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
111pub struct JobPayload {
112 pub job_id: Uuid,
113 pub run_id: Uuid,
114 pub repo_id: Uuid,
115 pub space_id: Uuid,
116 pub run_attempt: i32,
117 pub job_name: String,
118 pub clone_url: String,
119 pub commit_sha: String,
120 pub git_ref: String,
121 pub branch: Option<String>,
122 pub tag: Option<String>,
123 pub base_sha: Option<String>,
124 pub base_ref: Option<String>,
125 pub pr_number: Option<i64>,
126 pub event: String,
127 pub workflow_name: String,
128 pub workflow_file: String,
129 pub env: BTreeMap<String, String>,
130 pub secrets: BTreeMap<String, String>,
131 pub allow_git_token: bool,
132 pub container: Option<String>,
133 pub timeout_secs: u64,
134 pub steps: Vec<StepPayload>,
135 pub artifacts: Vec<ArtifactSpecDto>,
136 pub needs_artifacts: Vec<NeedsArtifactDto>,
137 pub lease: LeaseDto,
138 /// Trace context of the CI event that produced the run; lets the runner
139 /// continue the run's trace in its own process.
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub trace: Option<String>,
142 /// Request id of the CI event that produced the run.
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub request_id: Option<String>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub actions: Option<crate::actions::ActionsExecution>,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
150#[serde(rename_all = "snake_case")]
151pub enum StepReportState {
152 Started,
153 Finished,
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
157pub struct StepReport {
158 pub state: StepReportState,
159 #[serde(default)]
160 pub exit_code: Option<i32>,
161 #[serde(default)]
162 pub name: Option<String>,
163 #[serde(default)]
164 pub shell: Option<String>,
165 #[serde(default)]
166 pub working_directory: Option<String>,
167 #[serde(default)]
168 pub script: Option<String>,
169 #[serde(default)]
170 pub timeout_secs: Option<u64>,
171 #[serde(default)]
172 pub started_at: Option<OffsetDateTime>,
173 #[serde(default)]
174 pub finished_at: Option<OffsetDateTime>,
175 #[serde(default)]
176 pub step_key: Option<String>,
177 #[serde(default)]
178 pub stage: Option<String>,
179 #[serde(default)]
180 pub outcome: Option<String>,
181 #[serde(default)]
182 pub conclusion: Option<String>,
183 #[serde(default)]
184 pub outputs: BTreeMap<String, String>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
188pub struct LogChunk {
189 pub seq: i32,
190 pub text: String,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
194pub struct LogChunksRequest {
195 pub chunks: Vec<LogChunk>,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
199#[serde(rename_all = "snake_case")]
200pub enum JobFinishState {
201 Success,
202 Failure,
203 TimedOut,
204 Cancelled,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
208pub struct JobFinishRequest {
209 pub state: JobFinishState,
210 #[serde(default)]
211 pub exit_code: Option<i32>,
212 #[serde(default)]
213 pub error: Option<String>,
214 #[serde(default)]
215 pub outputs: BTreeMap<String, String>,
216 #[serde(default)]
217 pub lease_token: Option<String>,
218}
219
220impl Default for JobFinishRequest {
221 fn default() -> Self {
222 Self {
223 state: JobFinishState::Failure,
224 exit_code: None,
225 error: None,
226 outputs: Default::default(),
227 lease_token: None,
228 }
229 }
230}
231
232impl Default for StepReport {
233 fn default() -> Self {
234 Self {
235 state: StepReportState::Started,
236 exit_code: None,
237 name: None,
238 shell: None,
239 working_directory: None,
240 script: None,
241 timeout_secs: None,
242 started_at: None,
243 finished_at: None,
244 step_key: None,
245 stage: None,
246 outcome: None,
247 conclusion: None,
248 outputs: Default::default(),
249 }
250 }
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
254pub struct ArtifactInitRequest {
255 pub name: String,
256 pub size: i64,
257 #[serde(default)]
258 pub sha256: Option<String>,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
262pub struct ArtifactInitResponse {
263 pub artifact_id: Uuid,
264 pub upload_url: String,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
268pub struct ArtifactCompleteRequest {
269 pub size: i64,
270 #[serde(default)]
271 pub sha256: Option<String>,
272}
Deleted lib/ci-protocol/Cargo.toml
@@ -1,12 +0,0 @@
1[package]
2name = "ci-protocol"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7globset = { workspace = true }
8serde = { workspace = true, features = ["derive"] }
9serde_json = { workspace = true }
10utoipa = { workspace = true }
11time = { workspace = true }
12uuid = { workspace = true }
Deleted lib/config/src/ci.rs
@@ -1,116 +0,0 @@
1use serde::Deserialize;
2
3#[derive(Clone, Debug, Deserialize)]
4#[serde(default)]
5pub struct CiConfig {
6 pub enabled: bool,
7 pub secret_encryption_key: String,
8 pub event_queue_topic: String,
9 pub event_dlq_topic: String,
10 pub lease_secs: u64,
11 pub heartbeat_interval_secs: u64,
12 pub long_poll_timeout_secs: u64,
13 pub max_attempts: i32,
14 pub job_timeout_default_secs: u64,
15 pub job_timeout_max_secs: u64,
16 pub log_chunk_max_bytes: u64,
17 pub log_retention_days: i64,
18 pub artifact_retention_days: i64,
19 pub run_retention_days: i64,
20 pub artifact_max_bytes: u64,
21 pub outbound_max_attempts: i32,
22 pub outbound_backoff_base_secs: i64,
23 pub outbound_timeout_secs: u64,
24 pub scheduler_interval_secs: u64,
25 pub cancel_grace_secs: u64,
26 pub runner_stale_secs: u64,
27 pub actions_engine_binary: String,
28 pub actions_engine_timeout_secs: u64,
29 pub runtime_base_url: String,
30 pub cache_retention_days: i64,
31 pub cache_max_bytes: u64,
32 pub oidc_private_key: String,
33}
34
35impl Default for CiConfig {
36 fn default() -> Self {
37 Self {
38 enabled: false,
39 secret_encryption_key: String::new(),
40 event_queue_topic: "gitdata:events".to_string(),
41 event_dlq_topic: "gitdata:events:dlq".to_string(),
42 lease_secs: 60,
43 heartbeat_interval_secs: 15,
44 long_poll_timeout_secs: 30,
45 max_attempts: 2,
46 job_timeout_default_secs: 3600,
47 job_timeout_max_secs: 21600,
48 log_chunk_max_bytes: 262_144,
49 log_retention_days: 30,
50 artifact_retention_days: 30,
51 run_retention_days: 180,
52 artifact_max_bytes: 2 * 1024 * 1024 * 1024,
53 outbound_max_attempts: 5,
54 outbound_backoff_base_secs: 60,
55 outbound_timeout_secs: 10,
56 scheduler_interval_secs: 2,
57 cancel_grace_secs: 120,
58 runner_stale_secs: 180,
59 actions_engine_binary: "gitdata-actions-engine".into(),
60 actions_engine_timeout_secs: 30,
61 runtime_base_url: String::new(),
62 cache_retention_days: 7,
63 cache_max_bytes: 10 * 1024 * 1024 * 1024,
64 oidc_private_key: String::new(),
65 }
66 }
67}
68
69impl CiConfig {
70 pub fn validate(&self) -> Result<(), String> {
71 if !self.enabled {
72 return Ok(());
73 }
74 if self.secret_encryption_key.trim().is_empty() {
75 return Err(
76 "ci.secret_encryption_key must not be empty when ci is enabled".to_string(),
77 );
78 }
79 let decoded = base64::Engine::decode(
80 &base64::engine::general_purpose::STANDARD,
81 self.secret_encryption_key.trim(),
82 )
83 .map_err(|_| "ci.secret_encryption_key must be base64".to_string())?;
84 if decoded.len() < 32 {
85 return Err("ci.secret_encryption_key must decode to at least 32 bytes".to_string());
86 }
87 if self.event_queue_topic.trim().is_empty() || self.event_dlq_topic.trim().is_empty() {
88 return Err("ci event topics must not be empty".to_string());
89 }
90 if self.lease_secs == 0
91 || self.heartbeat_interval_secs == 0
92 || self.long_poll_timeout_secs == 0
93 || self.job_timeout_default_secs == 0
94 || self.job_timeout_max_secs == 0
95 || self.job_timeout_max_secs < self.job_timeout_default_secs
96 || self.log_chunk_max_bytes == 0
97 || self.scheduler_interval_secs == 0
98 || self.runner_stale_secs == 0
99 {
100 return Err("ci timing and size limits must be greater than zero".to_string());
101 }
102 if self.max_attempts <= 0 || self.outbound_max_attempts <= 0 {
103 return Err("ci attempt limits must be greater than zero".to_string());
104 }
105 if self.log_retention_days < 0
106 || self.artifact_retention_days < 0
107 || self.run_retention_days < 0
108 {
109 return Err("ci retention days must not be negative".to_string());
110 }
111 if self.heartbeat_interval_secs >= self.lease_secs {
112 return Err("ci.heartbeat_interval_secs must be less than ci.lease_secs".to_string());
113 }
114 Ok(())
115 }
116}
Modified lib/config/src/git.rs View lib/config/src/git.rs
@@ -62,7 +62,6 @@
6262 pub max_blob_size: u64,
6363 pub git_command_timeout_secs: u64,
6464 pub git_rpc_timeout_secs: u64,
65 pub sync: SyncSettings,
6665 pub ssh: GitSshConfig,
6766 pub base_url: String,
6867 pub http_clone_base_url: String,
@@ -84,7 +83,6 @@
8483 max_blob_size: 100 * 1024 * 1024,
8584 git_command_timeout_secs: 300,
8685 git_rpc_timeout_secs: 3600,
87 sync: SyncSettings::default(),
8886 ssh: GitSshConfig::default(),
8987 base_url: "http://localhost:3000".to_string(),
9088 http_clone_base_url: String::new(),
@@ -129,38 +127,6 @@
129127 inactivity_timeout: Some(Duration::from_secs(300)),
130128 keepalive_interval: Duration::from_secs(30),
131129 keepalive_max: 3,
132 }
133 }
134}
135
136#[derive(Clone, Debug, Deserialize)]
137#[serde(default)]
138pub struct SyncSettings {
139 pub lock_ttl_secs: u64,
140 pub max_commits_per_sync: usize,
141 pub concurrency: usize,
142 pub queue_topic: String,
143 pub dlq_topic: String,
144 pub max_lock_retries: u64,
145 pub max_languages: usize,
146 pub language_timeout_secs: u64,
147 pub brpop_timeout_secs: u64,
148 pub git_command_timeout_secs: u64,
149}
150
151impl Default for SyncSettings {
152 fn default() -> Self {
153 Self {
154 lock_ttl_secs: 120,
155 max_commits_per_sync: 5_000,
156 concurrency: 4,
157 queue_topic: "gitdata:tasks".to_string(),
158 dlq_topic: "gitdata:dlq".to_string(),
159 max_lock_retries: 5,
160 max_languages: 20,
161 language_timeout_secs: 30,
162 brpop_timeout_secs: 5,
163 git_command_timeout_secs: 120,
164130 }
165131 }
166132}
Deleted lib/config/src/health.rs
@@ -1,48 +0,0 @@
1use std::net::IpAddr;
2
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6#[serde(default)]
7pub struct HealthServiceConfig {
8 pub port: Option<u16>,
9}
10
11/// Health endpoints for binaries without a business HTTP listener
12/// (synchost). Apps that already serve HTTP (apihost, githost, im)
13/// expose /healthz + /readyz on their own port instead.
14#[derive(Debug, Clone, Default, Serialize, Deserialize)]
15#[serde(default)]
16pub struct HealthConfig {
17 pub enabled: Option<bool>,
18 pub bind_addr: Option<String>,
19 pub synchost: HealthServiceConfig,
20}
21
22impl HealthConfig {
23 pub fn enabled(&self) -> bool {
24 self.enabled.unwrap_or(true)
25 }
26
27 pub fn bind_addr(&self) -> &str {
28 self.bind_addr.as_deref().unwrap_or("0.0.0.0")
29 }
30
31 pub fn synchost_port(&self) -> u16 {
32 self.synchost.port.unwrap_or(3002)
33 }
34
35 pub fn is_valid(&self) -> Result<(), String> {
36 if !self.enabled() {
37 return Ok(());
38 }
39 let bind = self.bind_addr();
40 if bind.parse::<IpAddr>().is_err() {
41 return Err(format!("health.bind_addr is not a valid IP: {bind:?}"));
42 }
43 if self.synchost.port == Some(0) {
44 return Err("health.synchost.port must be greater than 0".to_string());
45 }
46 Ok(())
47 }
48}
Deleted lib/config/src/import.rs
@@ -1,77 +0,0 @@
1use serde::Deserialize;
2
3#[derive(Clone, Debug, Deserialize)]
4#[serde(default)]
5pub struct ImportConfig {
6 pub enabled: bool,
7 pub token_secret: String,
8 pub allowed_hosts: Vec<String>,
9 pub allow_insecure_tls: bool,
10 pub max_concurrent_jobs: usize,
11 pub clone_timeout_secs: u64,
12 pub api_timeout_secs: u64,
13 pub poll_interval_secs: u64,
14 pub max_attempts: i32,
15 pub max_repo_bytes: u64,
16 pub stale_lock_secs: u64,
17}
18
19impl Default for ImportConfig {
20 fn default() -> Self {
21 Self {
22 enabled: false,
23 token_secret: String::new(),
24 allowed_hosts: Vec::new(),
25 allow_insecure_tls: false,
26 max_concurrent_jobs: 2,
27 clone_timeout_secs: 3600,
28 api_timeout_secs: 30,
29 poll_interval_secs: 3,
30 max_attempts: 5,
31 max_repo_bytes: 0,
32 stale_lock_secs: 1800,
33 }
34 }
35}
36
37impl ImportConfig {
38 pub fn validate(&self) -> Result<(), String> {
39 if !self.enabled {
40 return Ok(());
41 }
42 if self.token_secret.trim().is_empty() {
43 return Err("import.token_secret must not be empty when import is enabled".to_string());
44 }
45 if self.max_concurrent_jobs == 0
46 || self.clone_timeout_secs == 0
47 || self.api_timeout_secs == 0
48 || self.poll_interval_secs == 0
49 || self.stale_lock_secs == 0
50 {
51 return Err("import limits and intervals must be greater than zero".to_string());
52 }
53 if self.max_attempts <= 0 {
54 return Err("import.max_attempts must be greater than zero".to_string());
55 }
56 for host in &self.allowed_hosts {
57 let normalized = host.trim().to_ascii_lowercase();
58 if normalized.is_empty() || normalized.contains("://") || normalized.contains('/') {
59 return Err(format!(
60 "import.allowed_hosts entry `{host}` must be a bare host name"
61 ));
62 }
63 }
64 Ok(())
65 }
66
67 pub fn host_allowed(&self, host: &str) -> bool {
68 let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
69 if host.is_empty() {
70 return false;
71 }
72 self.allowed_hosts.iter().any(|entry| {
73 let entry = entry.trim().trim_end_matches('.').to_ascii_lowercase();
74 !entry.is_empty() && (host == entry || host.ends_with(&format!(".{entry}")))
75 })
76 }
77}
Modified lib/config/src/lib.rs View lib/config/src/lib.rs
@@ -1,14 +1,11 @@
11pub mod apihost;
22pub mod auth;
33pub mod chat;
4pub mod ci;
54pub mod database;
65pub mod email;
76pub mod git;
87pub mod githost;
9pub mod health;
108pub mod im;
11pub mod import;
129pub mod internal;
1310pub mod issues;
1411pub mod livekit;
@@ -17,14 +14,11 @@
1714pub mod push;
1815pub mod quota;
1916pub mod redis;
20pub mod runner;
2117pub mod s3;
2218pub mod scoped;
23pub mod search;
2419pub mod space;
2520pub mod user;
2621pub mod web;
27pub mod webhook;
2822
2923pub mod error;
3024pub mod loader;
@@ -34,14 +28,11 @@
3428use crate::apihost::ApihostConfig;
3529use crate::auth::AuthConfig;
3630use crate::chat::ChatConfig;
37use crate::ci::CiConfig;
3831use crate::database::DatabaseConfig;
3932use crate::email::EmailConfig;
4033use crate::git::GitConfig;
4134use crate::githost::GithostConfig;
42use crate::health::HealthConfig;
4335use crate::im::ImServerConfig;
44use crate::import::ImportConfig;
4536use crate::internal::InternalConfig;
4637use crate::issues::IssueConfig;
4738use crate::livekit::LiveKitConfig;
@@ -49,13 +40,10 @@
4940use crate::pow::PowConfig;
5041use crate::push::PushConfig;
5142use crate::redis::RedisConfig;
52use crate::runner::RunnerConfig;
5343use crate::s3::S3Config;
54use crate::search::SearchConfig;
5544use crate::space::SpaceConfig;
5645use crate::user::UserConfig;
5746use crate::web::WebConfig;
58use crate::webhook::WebhookConfig;
5947use serde::Deserialize;
6048
6149#[derive(Clone, Debug, Default, Deserialize)]
@@ -65,8 +53,6 @@
6553 pub redis: RedisConfig,
6654 pub chat: ChatConfig,
6755 pub database: DatabaseConfig,
68 pub search: SearchConfig,
69 pub import: ImportConfig,
7056 pub pow: PowConfig,
7157 pub auth: AuthConfig,
7258 pub web: WebConfig,
@@ -81,11 +67,7 @@
8167 pub apihost: ApihostConfig,
8268 pub githost: GithostConfig,
8369 pub im: ImServerConfig,
84 pub health: HealthConfig,
8570 pub internal: InternalConfig,
86 pub ci: CiConfig,
87 pub webhook: WebhookConfig,
88 pub runner: RunnerConfig,
8971 pub push: PushConfig,
9072}
9173
@@ -100,15 +82,10 @@
10082 self.pow.validate().map_err(ConfigError::Validation)?;
10183 self.auth.validate().map_err(ConfigError::Validation)?;
10284 self.chat.is_valid().map_err(ConfigError::Validation)?;
103 self.search.validate().map_err(ConfigError::Validation)?;
104 self.import.validate().map_err(ConfigError::Validation)?;
10585 self.apihost.is_valid().map_err(ConfigError::Validation)?;
10686 self.githost.is_valid().map_err(ConfigError::Validation)?;
10787 self.im.is_valid().map_err(ConfigError::Validation)?;
108 self.health.is_valid().map_err(ConfigError::Validation)?;
10988 self.internal.is_valid().map_err(ConfigError::Validation)?;
110 self.ci.validate().map_err(ConfigError::Validation)?;
111 self.webhook.is_valid().map_err(ConfigError::Validation)?;
11289 self.livekit.is_valid().map_err(ConfigError::Validation)?;
11390 self.oauth.is_valid().map_err(ConfigError::Validation)?;
11491 self.push.is_valid().map_err(ConfigError::Validation)?;
Deleted lib/config/src/runner.rs
@@ -1,175 +0,0 @@
1use figment::Figment;
2use figment::providers::{Env, Format, Serialized, Toml};
3use serde::Deserialize;
4
5use crate::error::ConfigError;
6
7pub const RUNNER_ENV_PREFIX: &str = "APP_RUNNER__";
8pub const DEFAULT_CONFIG_PATH: &str = "config/default.toml";
9pub const LOCAL_CONFIG_PATH: &str = "config/local.toml";
10
11#[derive(Clone, Debug, Deserialize, serde::Serialize)]
12#[serde(default)]
13pub struct RunnerConfig {
14 pub server_url: String,
15 pub registration_token: String,
16 pub name: String,
17 pub labels: Vec<String>,
18 pub capacity: i32,
19 pub workspace_root: String,
20 pub credential_path: String,
21 pub docker_enabled: bool,
22 pub default_shell: String,
23 pub pat: String,
24 pub git_binary: String,
25 pub docker_binary: String,
26 pub clone_timeout_secs: u64,
27 pub default_job_timeout_secs: u64,
28 pub poll_timeout_secs: u64,
29 pub heartbeat_interval_secs: u64,
30 pub log_flush_interval_ms: u64,
31 pub log_flush_bytes: usize,
32 pub keep_workspace: bool,
33 pub once: bool,
34 pub actions_engine_binary: String,
35 pub actions_platforms: std::collections::BTreeMap<String, String>,
36 pub actions_node_runtimes: std::collections::BTreeMap<String, String>,
37 pub actions_cache_dir: String,
38 pub actions_action_server: String,
39 pub actions_action_token: String,
40 pub actions_groups: Vec<String>,
41}
42
43impl Default for RunnerConfig {
44 fn default() -> Self {
45 Self {
46 server_url: "http://127.0.0.1:3005".to_string(),
47 registration_token: String::new(),
48 name: String::new(),
49 labels: vec!["self-hosted".to_string()],
50 capacity: 1,
51 workspace_root: "/var/lib/gitdata-runner".to_string(),
52 credential_path: "/var/lib/gitdata-runner/credential".to_string(),
53 docker_enabled: false,
54 default_shell: "sh".to_string(),
55 pat: String::new(),
56 git_binary: "git".to_string(),
57 docker_binary: "docker".to_string(),
58 clone_timeout_secs: 1800,
59 default_job_timeout_secs: 3600,
60 poll_timeout_secs: 30,
61 heartbeat_interval_secs: 15,
62 log_flush_interval_ms: 1000,
63 log_flush_bytes: 65536,
64 keep_workspace: false,
65 once: false,
66 actions_engine_binary: "gitdata-actions-engine".into(),
67 actions_platforms: [
68 ("self-hosted".into(), "-self-hosted".into()),
69 (
70 "ubuntu-latest".into(),
71 "ghcr.io/catthehacker/ubuntu:act-24.04".into(),
72 ),
73 (
74 "ubuntu-24.04".into(),
75 "ghcr.io/catthehacker/ubuntu:act-24.04".into(),
76 ),
77 (
78 "ubuntu-22.04".into(),
79 "ghcr.io/catthehacker/ubuntu:act-22.04".into(),
80 ),
81 ]
82 .into(),
83 actions_node_runtimes: ["node12", "node16", "node20", "node24"]
84 .into_iter()
85 .map(|version| {
86 (
87 version.into(),
88 format!("/opt/gitdata/node/{version}/bin/node"),
89 )
90 })
91 .collect(),
92 actions_cache_dir: "/var/lib/gitdata-runner/actions".into(),
93 actions_action_server: "https://github.com".into(),
94 actions_action_token: String::new(),
95 actions_groups: Vec::new(),
96 }
97 }
98}
99
100impl RunnerConfig {
101 pub fn load() -> Result<Self, ConfigError> {
102 let _ = dotenvy::dotenv();
103 let mut figment = Figment::new();
104 if std::path::Path::new(DEFAULT_CONFIG_PATH).exists() {
105 figment = figment.merge(Toml::file(DEFAULT_CONFIG_PATH));
106 }
107 if std::path::Path::new(LOCAL_CONFIG_PATH).exists() {
108 figment = figment.merge(Toml::file(LOCAL_CONFIG_PATH));
109 }
110 // The shared config files keep runner settings under `[runner]`, while
111 // `APP_RUNNER__*` maps onto the flat keys of this struct; layer both so
112 // a file-only config is not silently ignored.
113 let from_file: Self = figment.extract_inner("runner").unwrap_or_default();
114 let figment =
115 Figment::from(Serialized::defaults(from_file)).merge(Env::prefixed(RUNNER_ENV_PREFIX));
116 let cfg: Self = figment.extract().map_err(ConfigError::from)?;
117 cfg.validate()?;
118 Ok(cfg)
119 }
120
121 pub fn validate(&self) -> Result<(), ConfigError> {
122 if self.server_url.trim().is_empty() {
123 return Err(ConfigError::Validation(
124 "runner.server_url must not be empty".to_string(),
125 ));
126 }
127 if self.labels.is_empty() || self.labels.iter().any(|l| l.trim().is_empty()) {
128 return Err(ConfigError::Validation(
129 "runner.labels must contain at least one non-empty label".to_string(),
130 ));
131 }
132 if self.capacity <= 0 {
133 return Err(ConfigError::Validation(
134 "runner.capacity must be greater than zero".to_string(),
135 ));
136 }
137 if self.workspace_root.trim().is_empty() || self.credential_path.trim().is_empty() {
138 return Err(ConfigError::Validation(
139 "runner workspace_root and credential_path must not be empty".to_string(),
140 ));
141 }
142 if self.default_shell.trim().is_empty() {
143 return Err(ConfigError::Validation(
144 "runner.default_shell must not be empty".to_string(),
145 ));
146 }
147 if self.clone_timeout_secs == 0
148 || self.default_job_timeout_secs == 0
149 || self.poll_timeout_secs == 0
150 || self.heartbeat_interval_secs == 0
151 {
152 return Err(ConfigError::Validation(
153 "runner timeouts must be greater than zero".to_string(),
154 ));
155 }
156 let _ = Env::prefixed(RUNNER_ENV_PREFIX);
157 Ok(())
158 }
159
160 #[must_use]
161 pub fn resolved_name(&self) -> String {
162 if self.name.trim().is_empty() {
163 hostname()
164 } else {
165 self.name.clone()
166 }
167 }
168}
169
170fn hostname() -> String {
171 std::env::var("HOSTNAME")
172 .ok()
173 .filter(|h| !h.trim().is_empty())
174 .unwrap_or_else(|| "gitdata-runner".to_string())
175}
Modified lib/config/src/scoped.rs View lib/config/src/scoped.rs
@@ -4,15 +4,12 @@
44use crate::apihost::ApihostConfig as ApihostSection;
55use crate::auth::AuthConfig;
66use crate::chat::ChatConfig;
7use crate::ci::CiConfig;
87use crate::database::DatabaseConfig;
98use crate::email::EmailConfig;
109use crate::error::ConfigError;
1110use crate::git::GitConfig;
1211use crate::githost::GithostConfig as GithostSection;
13use crate::health::HealthConfig;
1412use crate::im::ImServerConfig;
15use crate::import::ImportConfig;
1613use crate::issues::IssueConfig;
1714use crate::livekit::LiveKitConfig;
1815use crate::oauth::OAuthConfig;
@@ -21,11 +18,9 @@
2118use crate::quota::QuotaConfig;
2219use crate::redis::RedisConfig;
2320use crate::s3::S3Config;
24use crate::search::SearchConfig;
2521use crate::space::SpaceConfig;
2622use crate::user::UserConfig;
2723use crate::web::WebConfig;
28use crate::webhook::WebhookConfig;
2924use crate::{AppConfig, loader};
3025
3126pub trait ScopedConfig: DeserializeOwned + Sized {
@@ -52,71 +47,6 @@
5247
5348#[derive(Clone, Debug, Default, Deserialize)]
5449#[serde(default)]
55pub struct WebhookServiceConfig {
56 pub database: DatabaseConfig,
57 pub redis: RedisConfig,
58 pub s3: S3Config,
59 pub email: EmailConfig,
60 pub oauth: OAuthConfig,
61 pub web: WebConfig,
62 pub git: GitConfig,
63 pub ci: CiConfig,
64 pub webhook: WebhookConfig,
65 pub health: HealthConfig,
66}
67
68impl WebhookServiceConfig {
69 pub fn into_app_config(self) -> AppConfig {
70 AppConfig {
71 database: self.database,
72 redis: self.redis,
73 s3: self.s3,
74 email: self.email,
75 oauth: self.oauth,
76 web: self.web,
77 git: self.git,
78 ci: self.ci,
79 webhook: self.webhook,
80 health: self.health,
81 ..Default::default()
82 }
83 }
84}
85
86impl ScopedConfig for WebhookServiceConfig {
87 fn validate_scoped(&self) -> Result<(), String> {
88 self.database.validate()?;
89 self.redis.validate()?;
90 self.ci.validate()?;
91 self.webhook.is_valid()?;
92 self.oauth.is_valid()?;
93 self.health.is_valid()
94 }
95}
96
97#[derive(Clone, Debug, Default, Deserialize)]
98#[serde(default)]
99pub struct SynchostConfig {
100 pub database: DatabaseConfig,
101 pub redis: RedisConfig,
102 pub git: GitConfig,
103 pub health: HealthConfig,
104 pub search: SearchConfig,
105 pub import: ImportConfig,
106}
107
108impl ScopedConfig for SynchostConfig {
109 fn validate_scoped(&self) -> Result<(), String> {
110 self.database.validate()?;
111 self.redis.validate()?;
112 self.search.validate()?;
113 self.import.validate()?;
114 self.health.is_valid()
115 }
116}
117
118#[derive(Clone, Debug, Default, Deserialize)]
119#[serde(default)]
12050pub struct GithostConfig {
12151 pub database: DatabaseConfig,
12252 pub redis: RedisConfig,
@@ -126,7 +56,6 @@
12656 pub web: WebConfig,
12757 pub git: GitConfig,
12858 pub githost: GithostSection,
129 pub ci: CiConfig,
13059}
13160
13261impl GithostConfig {
@@ -140,7 +69,6 @@
14069 web: self.web,
14170 git: self.git,
14271 githost: self.githost,
143 ci: self.ci,
14472 ..Default::default()
14573 }
14674 }
@@ -159,8 +87,6 @@
15987#[derive(Clone, Debug, Default, Deserialize)]
16088#[serde(default)]
16189pub struct ApihostConfig {
162 pub ci: CiConfig,
163 pub webhook: WebhookConfig,
16490 pub s3: S3Config,
16591 pub redis: RedisConfig,
16692 pub chat: ChatConfig,
@@ -176,8 +102,6 @@
176102 pub email: EmailConfig,
177103 pub oauth: OAuthConfig,
178104 pub apihost: ApihostSection,
179 pub search: SearchConfig,
180 pub import: ImportConfig,
181105 pub livekit: LiveKitConfig,
182106 pub push: PushConfig,
183107}
@@ -185,8 +109,6 @@
185109impl ApihostConfig {
186110 pub fn into_app_config(self) -> AppConfig {
187111 AppConfig {
188 ci: self.ci,
189 webhook: self.webhook,
190112 s3: self.s3,
191113 redis: self.redis,
192114 chat: self.chat,
@@ -202,8 +124,6 @@
202124 email: self.email,
203125 oauth: self.oauth,
204126 apihost: self.apihost,
205 search: self.search,
206 import: self.import,
207127 livekit: self.livekit,
208128 push: self.push,
209129 ..Default::default()
@@ -213,14 +133,11 @@
213133
214134impl ScopedConfig for ApihostConfig {
215135 fn validate_scoped(&self) -> Result<(), String> {
216 self.ci.validate()?;
217136 self.redis.validate()?;
218137 self.database.validate()?;
219138 self.pow.validate()?;
220139 self.auth.validate()?;
221140 self.chat.is_valid()?;
222 self.search.validate()?;
223 self.import.validate()?;
224141 self.apihost.is_valid()?;
225142 self.livekit.is_valid()?;
226143 self.oauth.is_valid()?;
Deleted lib/config/src/search.rs
@@ -1,86 +0,0 @@
1use serde::Deserialize;
2
3#[derive(Clone, Debug, Deserialize)]
4#[serde(default)]
5pub struct SearchConfig {
6 pub enabled: bool,
7 pub url: String,
8 pub api_key: String,
9 pub index_prefix: String,
10 pub workers: usize,
11 pub max_file_bytes: usize,
12 pub chunk_lines: usize,
13 pub batch_size: usize,
14 pub poll_interval_secs: u64,
15 pub task_timeout_secs: u64,
16 pub incremental: bool,
17 pub incremental_max_changed_files: usize,
18 pub git_timeout_secs: u64,
19 pub cat_file_batch: bool,
20 pub parse_threads: usize,
21 pub meili_inflight: usize,
22}
23
24impl Default for SearchConfig {
25 fn default() -> Self {
26 Self {
27 enabled: false,
28 url: "http://127.0.0.1:7700".to_string(),
29 api_key: String::new(),
30 index_prefix: "gitdata".to_string(),
31 workers: 2,
32 max_file_bytes: 1024 * 1024,
33 chunk_lines: 160,
34 batch_size: 100,
35 poll_interval_secs: 2,
36 task_timeout_secs: 120,
37 incremental: true,
38 incremental_max_changed_files: 2000,
39 git_timeout_secs: 120,
40 cat_file_batch: true,
41 parse_threads: 4,
42 meili_inflight: 4,
43 }
44 }
45}
46
47impl SearchConfig {
48 pub fn validate(&self) -> Result<(), String> {
49 if !self.enabled {
50 return Ok(());
51 }
52 if !(self.url.starts_with("http://") || self.url.starts_with("https://")) {
53 return Err("search.url must be an http(s) URL".to_string());
54 }
55 if self.api_key.trim().is_empty() {
56 return Err("search.api_key must not be empty when search is enabled".to_string());
57 }
58 if self.index_prefix.trim().is_empty() {
59 return Err("search.index_prefix must not be empty".to_string());
60 }
61 if self.max_file_bytes == 0 || self.chunk_lines == 0 || self.batch_size == 0 {
62 return Err("search limits must be greater than zero".to_string());
63 }
64 if self.workers == 0 || self.workers > 64 {
65 return Err("search.workers must be between 1 and 64".to_string());
66 }
67 if self.poll_interval_secs == 0 || self.task_timeout_secs == 0 {
68 return Err("search intervals must be greater than zero".to_string());
69 }
70 if self.incremental_max_changed_files == 0 {
71 return Err(
72 "search.incremental_max_changed_files must be greater than zero".to_string(),
73 );
74 }
75 if self.git_timeout_secs == 0 {
76 return Err("search.git_timeout_secs must be greater than zero".to_string());
77 }
78 if self.parse_threads == 0 || self.parse_threads > 64 {
79 return Err("search.parse_threads must be between 1 and 64".to_string());
80 }
81 if self.meili_inflight == 0 || self.meili_inflight > 64 {
82 return Err("search.meili_inflight must be between 1 and 64".to_string());
83 }
84 Ok(())
85 }
86}
Deleted lib/config/src/webhook.rs
@@ -1,43 +0,0 @@
1use std::net::IpAddr;
2
3use serde::Deserialize;
4
5#[derive(Clone, Debug, Deserialize)]
6#[serde(default)]
7#[derive(Default)]
8pub struct WebhookConfig {
9 pub enabled: Option<bool>,
10 pub bind_addr: Option<String>,
11 pub port: Option<u16>,
12 pub public_base_url: String,
13}
14
15impl WebhookConfig {
16 pub fn enabled(&self) -> bool {
17 self.enabled.unwrap_or(true)
18 }
19
20 pub fn bind_addr(&self) -> &str {
21 self.bind_addr.as_deref().unwrap_or("0.0.0.0")
22 }
23
24 pub fn port(&self) -> u16 {
25 self.port.unwrap_or(3005)
26 }
27
28 pub fn is_valid(&self) -> Result<(), String> {
29 if !self.enabled() {
30 return Ok(());
31 }
32 if self.bind_addr().parse::<IpAddr>().is_err() {
33 return Err(format!(
34 "webhook.bind_addr is not a valid IP: {:?}",
35 self.bind_addr()
36 ));
37 }
38 if self.port == Some(0) {
39 return Err("webhook.port must be greater than 0".to_string());
40 }
41 Ok(())
42 }
43}
Modified lib/config/Cargo.toml View lib/config/Cargo.toml
@@ -9,7 +9,6 @@
99base64 = { workspace = true }
1010figment = { workspace = true }
1111dotenvy = { workspace = true }
12ipnet = { workspace = true }
1312thiserror = { workspace = true }
1413tracing = { workspace = true }
1514url = { workspace = true }
Modified lib/export/src/metrics.rs View lib/export/src/metrics.rs
@@ -2,7 +2,7 @@
22use std::time::Duration;
33
44use opentelemetry::global;
5use opentelemetry::metrics::{Counter, Gauge, Histogram, UpDownCounter};
5use opentelemetry::metrics::{Counter, Gauge, UpDownCounter};
66use opentelemetry::{KeyValue, metrics::Meter};
77
88use crate::redis::AppRedis;
@@ -25,42 +25,6 @@
2525 .build()
2626});
2727
28static CI_EVENTS: LazyLock<Counter<u64>> = LazyLock::new(|| {
29 meter()
30 .u64_counter("ci.events")
31 .with_description("CI events consumed by kind")
32 .build()
33});
34
35static CI_RUNS_CREATED: LazyLock<Counter<u64>> = LazyLock::new(|| {
36 meter()
37 .u64_counter("ci.runs.created")
38 .with_description("Workflow runs created from events")
39 .build()
40});
41
42static CI_JOBS_FINISHED: LazyLock<Counter<u64>> = LazyLock::new(|| {
43 meter()
44 .u64_counter("ci.jobs.finished")
45 .with_description("Runner jobs finished by conclusion")
46 .build()
47});
48
49static CI_JOB_DURATION: LazyLock<Histogram<f64>> = LazyLock::new(|| {
50 meter()
51 .f64_histogram("ci.job.duration")
52 .with_unit("s")
53 .with_description("Runner job wall-clock duration")
54 .build()
55});
56
57static CI_WEBHOOK_DELIVERIES: LazyLock<Counter<u64>> = LazyLock::new(|| {
58 meter()
59 .u64_counter("ci.webhook.deliveries")
60 .with_description("Outbound CI webhook deliveries by outcome")
61 .build()
62});
63
6428static PUSH_DELIVERIES: LazyLock<Counter<u64>> = LazyLock::new(|| {
6529 meter()
6630 .u64_counter("push.deliveries")
@@ -82,27 +46,6 @@
8246 .build()
8347});
8448
85static SYNC_JOBS: LazyLock<Counter<u64>> = LazyLock::new(|| {
86 meter()
87 .u64_counter("git.sync.jobs")
88 .with_description("Repository sync jobs by outcome")
89 .build()
90});
91
92static IMPORT_JOBS: LazyLock<Counter<u64>> = LazyLock::new(|| {
93 meter()
94 .u64_counter("import.jobs")
95 .with_description("Repository import jobs by outcome")
96 .build()
97});
98
99static SEARCH_JOBS: LazyLock<Counter<u64>> = LazyLock::new(|| {
100 meter()
101 .u64_counter("search.index.jobs")
102 .with_description("Search index jobs by outcome")
103 .build()
104});
105
10649static QUEUE_DEPTH: LazyLock<Gauge<i64>> = LazyLock::new(|| {
10750 meter()
10851 .i64_gauge("queue.depth")
@@ -121,30 +64,6 @@
12164 );
12265}
12366
124pub fn ci_event(kind: &'static str) {
125 CI_EVENTS.add(1, &[KeyValue::new("kind", kind)]);
126}
127
128pub fn ci_runs_created(event: &'static str, count: usize) {
129 if count > 0 {
130 CI_RUNS_CREATED.add(count as u64, &[KeyValue::new("event", event)]);
131 }
132}
133
134pub fn ci_job_finished(conclusion: &'static str, duration: Option<Duration>) {
135 CI_JOBS_FINISHED.add(1, &[KeyValue::new("conclusion", conclusion)]);
136 if let Some(duration) = duration {
137 CI_JOB_DURATION.record(
138 duration.as_secs_f64(),
139 &[KeyValue::new("conclusion", conclusion)],
140 );
141 }
142}
143
144pub fn ci_webhook_delivery(outcome: &'static str) {
145 CI_WEBHOOK_DELIVERIES.add(1, &[KeyValue::new("outcome", outcome)]);
146}
147
14867pub fn push_delivery(outcome: &'static str) {
14968 PUSH_DELIVERIES.add(1, &[KeyValue::new("outcome", outcome)]);
15069}
@@ -179,18 +98,6 @@
17998
18099pub fn chat_webhook_delivery(outcome: &'static str) {
181100 CHAT_WEBHOOK_DELIVERIES.add(1, &[KeyValue::new("outcome", outcome)]);
182}
183
184pub fn sync_job(outcome: &'static str) {
185 SYNC_JOBS.add(1, &[KeyValue::new("outcome", outcome)]);
186}
187
188pub fn import_job(outcome: &'static str) {
189 IMPORT_JOBS.add(1, &[KeyValue::new("outcome", outcome)]);
190}
191
192pub fn search_index_job(outcome: &'static str) {
193 SEARCH_JOBS.add(1, &[KeyValue::new("outcome", outcome)]);
194101}
195102
196103const QUEUE_DEPTH_INTERVAL: Duration = Duration::from_secs(30);
Modified lib/export/src/notify.rs View lib/export/src/notify.rs
@@ -1,7 +1,6 @@
11use std::sync::{Arc, Mutex};
22
33use serde_json::Value;
4use uuid::Uuid;
54
65use crate::redis::AppRedis;
76
Modified lib/export/src/urls.rs View lib/export/src/urls.rs
@@ -1,4 +1,3 @@
1use uuid::Uuid;
21
32/// Object-storage locator prefix written by every upload endpoint.
43const STORAGE_SCHEME: &str = "storage://";
Modified lib/git/src/service/repos.rs View lib/git/src/service/repos.rs
@@ -20,8 +20,7 @@
2020pub const DEFAULT_BRANCH: &str = "main";
2121
2222/// 仓库页签中可由设置重排序的中间页签(代码固定最前、设置固定最后)。
23pub const REORDERABLE_REPO_TABS: [&str; 5] =
24 ["commits", "pulls", "branches", "pipelines", "releases"];
23pub const REORDERABLE_REPO_TABS: [&str; 4] = ["commits", "pulls", "branches", "releases"];
2524
2625/// 归一化页签顺序:只保留已知中间页签、去重、限制长度;空 = 默认顺序。
2726pub fn normalize_repo_tab_order(order: &[String]) -> Vec<String> {
@@ -73,8 +72,6 @@
7372 pub ssh_clone_url: Option<String>,
7473 /// 是否允许在本仓库创建合并请求(关闭后页签隐藏、接口拒绝)。
7574 pub allow_pull_requests: bool,
76 /// 流水线页签是否公开展示(关闭后仅空间成员可见)。
77 pub pipelines_public: bool,
7875 /// 中间页签顺序;空数组 = 默认顺序。
7976 pub tab_order: Vec<String>,
8077 pub created_at: OffsetDateTime,
@@ -164,7 +161,6 @@
164161 http_clone_url: None,
165162 ssh_clone_url: None,
166163 allow_pull_requests: m.allow_pull_requests,
167 pipelines_public: m.pipelines_public,
168164 tab_order: tab_order_from_json(m.tab_order.as_ref()),
169165 created_at: m.created_at,
170166 updated_at: m.updated_at,
@@ -208,8 +204,6 @@
208204 pub default_branch: Option<String>,
209205 #[serde(default)]
210206 pub allow_pull_requests: Option<bool>,
211 #[serde(default)]
212 pub pipelines_public: Option<bool>,
213207 /// 中间页签顺序;传空数组恢复默认顺序。
214208 #[serde(default)]
215209 pub tab_order: Option<Vec<String>>,
@@ -416,7 +410,6 @@
416410 allow_merge_commits: Set(true),
417411 object_format: Set("sha1".to_string()),
418412 allow_pull_requests: Set(true),
419 pipelines_public: Set(true),
420413 tab_order: Set(None),
421414 };
422415 let repo = repo_row.insert(&txn).await?;
@@ -603,9 +596,6 @@
603596 // 页签公开性与合并请求开关互相独立:只传其中一个时不动其余。
604597 if let Some(value) = cmd.allow_pull_requests {
605598 am.allow_pull_requests = Set(value);
606 }
607 if let Some(value) = cmd.pipelines_public {
608 am.pipelines_public = Set(value);
609599 }
610600 if let Some(order) = &cmd.tab_order {
611601 am.tab_order = Set(Some(serde_json::json!(normalize_repo_tab_order(order))));
Modified lib/git/src/share/access.rs View lib/git/src/share/access.rs
@@ -4,7 +4,6 @@
44use models::repo::repo_collaborations::{self, CollaborationMode};
55use models::users::access_scope::AccessKeyScope;
66use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
7use sha2::{Digest, Sha256};
87use uuid::Uuid;
98
109use crate::context::facade::GitContext;
@@ -78,56 +77,11 @@
7877 auth: &BasicAuth,
7978 ctx: &GitContext,
8079) -> Result<i64, GitError> {
81 if let Some(token) = auth
82 .token
83 .as_deref()
84 .filter(|token| token.starts_with("ci_"))
85 {
86 return verify_ci_job_token(svc, token, ctx, true).await;
87 }
8880 let uid = resolve_caller_for_write(svc, auth).await?;
8981 authorize_repo_access(svc, uid, ctx, true, false).await?;
9082 Ok(uid)
9183}
9284
93async fn verify_ci_job_token(
94 svc: &GitService,
95 token: &str,
96 ctx: &GitContext,
97 write: bool,
98) -> Result<i64, GitError> {
99 let (id, secret) = token
100 .strip_prefix("ci_")
101 .and_then(|value| value.split_once('.'))
102 .ok_or(GitError::Unauthorized)?;
103 let id = Uuid::parse_str(id).map_err(|_| GitError::Unauthorized)?;
104 let row = models::ci::job_token::Entity::find_by_id(id)
105 .one(svc.db_write())
106 .await?
107 .ok_or(GitError::Unauthorized)?;
108 let hash = sha256_hex(secret.as_bytes());
109 if !constant_time_eq(hash.as_bytes(), row.token_hash.as_bytes())
110 || row.expires_at <= time::OffsetDateTime::now_utc()
111 {
112 return Err(GitError::Unauthorized);
113 }
114 if row.repo_id != ctx.model.id
115 || (write && !row.contents_write)
116 || (!write && !row.contents_read)
117 {
118 return Err(GitError::Forbidden);
119 }
120 let job = models::ci::workflow_job::Entity::find_by_id(row.job_id)
121 .one(svc.db_write())
122 .await?
123 .ok_or(GitError::Unauthorized)?;
124 if !job.state.is_active() || job.lease_token.as_deref() != Some(row.lease_token.as_str()) {
125 return Err(GitError::Unauthorized);
126 }
127 authorize_repo_access(svc, row.actor_id, ctx, write, false).await?;
128 Ok(row.actor_id)
129}
130
13185/// HTTP read-path auth: `Ok(None)` only for anonymous reads on public
13286/// repos; otherwise returns `Unauthorized` / `Forbidden` / `Some(uid)`.
13387pub async fn allow_repo_read(
@@ -135,13 +89,6 @@
13589 auth: &BasicAuth,
13690 ctx: &GitContext,
13791) -> Result<Option<i64>, GitError> {
138 if let Some(token) = auth
139 .token
140 .as_deref()
141 .filter(|token| token.starts_with("ci_"))
142 {
143 return verify_ci_job_token(svc, token, ctx, false).await.map(Some);
144 }
14592 let Some(token) = &auth.token else {
14693 if ctx.model.is_private {
14794 return Err(GitError::Unauthorized);
@@ -281,20 +228,4 @@
281228 Err(GitError::Internal(format!(
282229 "dir tree deeper than {MAX_DIR_DEPTH} levels"
283230 )))
284}
285
286fn sha256_hex(data: &[u8]) -> String {
287 let digest = Sha256::digest(data);
288 digest.iter().map(|b| format!("{b:02x}")).collect()
289}
290
291fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
292 if a.len() != b.len() {
293 return false;
294 }
295 let mut diff: u8 = 0;
296 for (x, y) in a.iter().zip(b.iter()) {
297 diff |= x ^ y;
298 }
299 diff == 0
300231}
Deleted lib/git/src/sync/error.rs
@@ -1,47 +0,0 @@
1use std::io;
2
3use crate::rpcgit::error::BabyError;
4use redis::RedisError;
5use thiserror::Error;
6
7#[derive(Debug, Error)]
8pub enum SyncError {
9 #[error("repository sync lock is held by another worker")]
10 Locked,
11
12 #[error("database error: {0}")]
13 Db(#[from] sea_orm::DbErr),
14
15 #[error("redis error: {0}")]
16 Redis(#[from] RedisError),
17
18 #[error("git error: {0}")]
19 Git(#[from] BabyError),
20
21 #[error("i/o error: {0}")]
22 Io(#[from] io::Error),
23
24 #[error("operation timed out: {0}")]
25 Timeout(&'static str),
26
27 #[error("repository not found: {0}")]
28 NotFound(String),
29
30 #[error("invalid sync task: {0}")]
31 InvalidTask(String),
32
33 #[error("invalid license: {0}")]
34 InvalidLicense(String),
35
36 #[error("internal error: {0}")]
37 Internal(String),
38}
39
40pub type SyncResult<T> = Result<T, SyncError>;
41
42impl SyncError {
43 #[must_use]
44 pub fn is_locked(&self) -> bool {
45 matches!(self, SyncError::Locked)
46 }
47}
Deleted lib/git/src/sync/event.rs
@@ -1,126 +0,0 @@
1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4use crate::sync::queue::SyncQueue;
5use config::git::SyncSettings;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
8pub struct PostReceiveRefUpdate {
9 pub name: String,
10 pub old_oid: String,
11 pub new_oid: String,
12}
13
14impl PostReceiveRefUpdate {
15 #[must_use]
16 pub fn new(
17 name: impl Into<String>,
18 old_oid: impl Into<String>,
19 new_oid: impl Into<String>,
20 ) -> Self {
21 Self {
22 name: name.into(),
23 old_oid: old_oid.into(),
24 new_oid: new_oid.into(),
25 }
26 }
27
28 #[must_use]
29 pub fn is_delete(&self) -> bool {
30 self.is_zero(self.new_oid.as_str())
31 }
32
33 #[must_use]
34 pub fn is_create(&self) -> bool {
35 self.is_zero(self.old_oid.as_str()) && !self.is_zero(self.new_oid.as_str())
36 }
37
38 fn is_zero(&self, oid: &str) -> bool {
39 oid.is_empty() || oid.chars().all(|c| c == '0')
40 }
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44pub struct PostReceiveTask {
45 pub id: Uuid,
46 pub repo_id: Uuid,
47 pub channel_id: Uuid,
48 pub actor_id: Option<i64>,
49 pub refs: Vec<PostReceiveRefUpdate>,
50 #[serde(default)]
51 pub trace: Option<String>,
52 #[serde(default)]
53 pub request_id: Option<String>,
54}
55
56impl PostReceiveTask {
57 #[must_use]
58 pub fn new(
59 repo_id: Uuid,
60 channel_id: Uuid,
61 actor_id: Option<i64>,
62 refs: Vec<PostReceiveRefUpdate>,
63 ) -> Self {
64 Self {
65 id: Uuid::now_v7(),
66 repo_id,
67 channel_id,
68 actor_id,
69 refs,
70 trace: export::telemetry::traceparent(),
71 request_id: export::telemetry::request_id(),
72 }
73 }
74}
75
76#[derive(Debug, thiserror::Error)]
77pub enum EnqueueError {
78 #[error("serialize task: {0}")]
79 Serialize(#[from] serde_json::Error),
80 #[error("queue error: {0}")]
81 Queue(String),
82}
83
84pub async fn enqueue_post_receive(
85 queue: &SyncQueue,
86 settings: &SyncSettings,
87 task: &PostReceiveTask,
88) -> Result<(), EnqueueError> {
89 let payload = serde_json::to_vec(task)?;
90 let queue_len = queue
91 .publish_raw(
92 &settings.queue_topic,
93 Some(task.repo_id.as_bytes()),
94 &payload,
95 )
96 .await
97 .map_err(|e| EnqueueError::Queue(e.to_string()))?;
98 tracing::info!(
99 task_id = %task.id,
100 repo_id = %task.repo_id,
101 refs = task.refs.len(),
102 topic = %settings.queue_topic,
103 queue_len,
104 "sync task enqueued"
105 );
106 Ok(())
107}
108
109pub async fn enqueue_post_receive_dlq(
110 queue: &SyncQueue,
111 settings: &SyncSettings,
112 task: &PostReceiveTask,
113) -> Result<(), EnqueueError> {
114 let payload = serde_json::to_vec(task)?;
115 queue
116 .publish_raw(&settings.dlq_topic, Some(task.repo_id.as_bytes()), &payload)
117 .await
118 .map_err(|e| EnqueueError::Queue(e.to_string()))?;
119 tracing::debug!(
120 task_id = %task.id,
121 repo_id = %task.repo_id,
122 topic = %settings.dlq_topic,
123 "task enqueued to dlq"
124 );
125 Ok(())
126}
Deleted lib/git/src/sync/mod.rs
@@ -1,7 +0,0 @@
1pub mod error;
2pub mod event;
3pub mod queue;
4
5pub use error::{SyncError, SyncResult};
6pub use event::{EnqueueError as SyncEnqueueError, PostReceiveRefUpdate, PostReceiveTask};
7pub use queue::SyncQueue;
Deleted lib/git/src/sync/queue.rs
@@ -1,247 +0,0 @@
1use export::{AppRedis, RedisConnection};
2use redis::aio::MultiplexedConnection;
3
4use crate::sync::error::{SyncError, SyncResult};
5
6macro_rules! dispatch {
7 ($conn:expr, |$c:ident| $body:expr) => {
8 match &mut $conn {
9 RedisConnection::Standalone($c) => $body,
10 RedisConnection::Cluster($c) => $body,
11 }
12 };
13}
14
15const INCR_EXPIRE_SCRIPT: &str = "local c = redis.call('INCR', KEYS[1])\nif c == 1 then\n redis.call('EXPIRE', KEYS[1], ARGV[1])\nend\nreturn c";
16
17#[derive(Clone)]
18pub struct SyncQueue {
19 redis: AppRedis,
20 consumer_conns: Vec<MultiplexedConnection>,
21 /// cluster 模式:每个消费者一组到所有 master 的连接(按 slot 选择)
22 cluster_consumer_groups: Vec<Vec<MultiplexedConnection>>,
23}
24
25impl SyncQueue {
26 #[must_use]
27 pub fn new(redis: AppRedis) -> Self {
28 Self {
29 redis,
30 consumer_conns: Vec::new(),
31 cluster_consumer_groups: Vec::new(),
32 }
33 }
34
35 pub async fn with_consumer_conns(redis: AppRedis, count: usize) -> SyncResult<Self> {
36 let count = count.max(1);
37
38 // cluster 模式:BRPOP 是对单个 key 的操作,必须打到该 key slot 的 master,
39 // 否则会收到 MOVED。这里按哨兵轮询的方式给每个消费者预建到所有 master 的连接,
40 // 消费时再按 slot 选择对应连接。standalone 模式维持单连接。
41 let mut consumer_conns = Vec::with_capacity(count);
42 if redis.is_cluster() {
43 let masters = redis.cluster_master_connections().await.map_err(|e| {
44 SyncError::Internal(format!("open cluster master connections: {e}"))
45 })?;
46 if masters.is_empty() {
47 return Err(SyncError::Internal(
48 "redis cluster reported no master nodes".to_string(),
49 ));
50 }
51 // 每个消费者一组 master 连接;取模轮询避免所有消费者压在同一节点
52 let mut groups = Vec::with_capacity(count);
53 for i in 0..count {
54 let mut group = Vec::with_capacity(masters.len());
55 for offset in 0..masters.len() {
56 let idx = (i + offset) % masters.len();
57 group.push(masters[idx].clone());
58 }
59 groups.push(group);
60 }
61 tracing::debug!(
62 count,
63 masters = masters.len(),
64 "sync queue cluster consumer connections opened"
65 );
66 return Ok(Self {
67 redis,
68 consumer_conns: Vec::new(),
69 cluster_consumer_groups: groups,
70 });
71 }
72
73 for _ in 0..count {
74 let conn = open_multiplexed(&redis).await?;
75 consumer_conns.push(conn);
76 }
77 tracing::debug!(count, "sync queue consumer connections opened");
78 Ok(Self {
79 redis,
80 consumer_conns,
81 cluster_consumer_groups: Vec::new(),
82 })
83 }
84
85 #[must_use]
86 pub fn redis(&self) -> &AppRedis {
87 &self.redis
88 }
89
90 #[must_use]
91 pub fn consumer_conns(&self) -> &[MultiplexedConnection] {
92 &self.consumer_conns
93 }
94
95 pub async fn consume(
96 &self,
97 key: &str,
98 timeout_secs: u64,
99 slot: usize,
100 ) -> SyncResult<Option<Vec<u8>>> {
101 let res: Option<(String, Vec<u8>)> = if timeout_secs == 0 {
102 let mut conn = self.redis.connection_clone();
103 dispatch!(conn, |c| {
104 redis::cmd("RPOP").arg(key).query_async(c).await
105 })?
106 } else if !self.cluster_consumer_groups.is_empty() {
107 // cluster:BRPOP 必须落在持有该 key slot 的 master 上,否则会 MOVED。
108 // 先算 KEYSLOT,再到负责该 slot 的 master 上消费。
109 let conn = self.cluster_master_for_key(key).await?;
110 let mut conn = conn;
111 redis::cmd("BRPOP")
112 .arg(key)
113 .arg(timeout_secs)
114 .query_async(&mut conn)
115 .await?
116 } else if let Some(conn) = self.consumer_conns.get(slot).cloned() {
117 let mut conn = conn;
118 redis::cmd("BRPOP")
119 .arg(key)
120 .arg(timeout_secs)
121 .query_async(&mut conn)
122 .await?
123 } else {
124 let mut conn = self.redis.connection_clone();
125 dispatch!(conn, |c| {
126 redis::cmd("BRPOP")
127 .arg(key)
128 .arg(timeout_secs)
129 .query_async(c)
130 .await
131 })?
132 };
133 Ok(res.map(|(_, v)| v))
134 }
135
136 pub async fn publish_raw(
137 &self,
138 key: &str,
139 _partition: Option<&[u8]>,
140 payload: &[u8],
141 ) -> SyncResult<i64> {
142 let mut conn = self.redis.connection_clone();
143 let n: i64 = dispatch!(conn, |c| {
144 redis::cmd("LPUSH")
145 .arg(key)
146 .arg(payload)
147 .query_async(c)
148 .await
149 })?;
150 tracing::debug!(
151 topic = key,
152 queue_len = n,
153 payload_bytes = payload.len(),
154 "sync queue message pushed"
155 );
156 Ok(n)
157 }
158
159 pub async fn requeue(&self, key: &str, payload: &[u8]) -> SyncResult<i64> {
160 self.publish_raw(key, None, payload).await
161 }
162
163 pub async fn incr_with_ttl(&self, key: &str, ttl_secs: u64) -> SyncResult<i64> {
164 let mut conn = self.redis.connection_clone();
165 let n: i64 = dispatch!(conn, |c| {
166 redis::cmd("EVAL")
167 .arg(INCR_EXPIRE_SCRIPT)
168 .arg(1)
169 .arg(key)
170 .arg(ttl_secs)
171 .query_async(c)
172 .await
173 })?;
174 Ok(n)
175 }
176
177 /// 计算 key 所属 slot,并返回负责该 slot 的 master 连接(cluster 模式)。
178 async fn cluster_master_for_key(&self, key: &str) -> SyncResult<MultiplexedConnection> {
179 let group = self
180 .cluster_consumer_groups
181 .first()
182 .ok_or_else(|| SyncError::Internal("no cluster consumer connections".to_string()))?;
183
184 let mut conn = self.redis.connection_clone();
185 let key_slot: u16 = {
186 let RedisConnection::Cluster(cluster) = &mut conn else {
187 return Err(SyncError::Internal(
188 "cluster master lookup on non-cluster connection".to_string(),
189 ));
190 };
191 redis::cmd("CLUSTER")
192 .arg("KEYSLOT")
193 .arg(key)
194 .query_async(cluster)
195 .await
196 .map_err(|e| SyncError::Internal(format!("CLUSTER KEYSLOT failed: {e}")))?
197 };
198
199 let ranges = self
200 .redis
201 .cluster_slot_ranges()
202 .await
203 .map_err(|e| SyncError::Internal(format!("cluster slot ranges: {e}")))?;
204
205 let index = ranges
206 .iter()
207 .find(|(start, end, _)| (*start..=*end).contains(&(key_slot as i64)))
208 .map(|(_, _, idx)| *idx)
209 .ok_or_else(|| SyncError::Internal(format!("no master owns slot {key_slot}")))?;
210
211 group
212 .get(index)
213 .or_else(|| group.first())
214 .cloned()
215 .ok_or_else(|| SyncError::Internal("cluster consumer connection missing".to_string()))
216 }
217
218 pub async fn delete(&self, key: &str) -> SyncResult<()> {
219 let mut conn = self.redis.connection_clone();
220 let _: i64 = dispatch!(conn, |c| {
221 redis::cmd("DEL").arg(key).query_async(c).await
222 })?;
223 Ok(())
224 }
225}
226
227async fn open_multiplexed(redis: &AppRedis) -> SyncResult<MultiplexedConnection> {
228 // cluster 模式配置是逗号分隔的节点列表;BRPOP 消费者连单个节点即可
229 // (`redis::Client::open` 无法解析多节点串)。
230 let url = redis
231 .config()
232 .url()
233 .split(',')
234 .map(str::trim)
235 .find(|node| !node.is_empty())
236 .ok_or_else(|| SyncError::Internal("redis url contains no nodes".to_string()))?
237 .to_string();
238 let client = redis::Client::open(url.as_str())
239 .map_err(|e| SyncError::Internal(format!("open redis client for consumer: {e}")))?;
240 let config = redis::AsyncConnectionConfig::new()
241 .set_response_timeout(None)
242 .set_connection_timeout(Some(redis.config().connection_timeout()));
243 client
244 .get_multiplexed_async_connection_with_config(&config)
245 .await
246 .map_err(|e| SyncError::Internal(format!("open multiplexed consumer connection: {e}")))
247}
Modified lib/git/src/lib.rs View lib/git/src/lib.rs
@@ -7,4 +7,3 @@
77pub mod rpcgit;
88pub mod service;
99pub mod share;
10pub mod sync;
Modified lib/git/Cargo.toml View lib/git/Cargo.toml
@@ -7,7 +7,6 @@
77kenshell = { workspace = true }
88tonic = { workspace = true }
99prost-types = { workspace = true }
10ci-protocol = { workspace = true }
1110export = { workspace = true }
1211config = { workspace = true }
1312models = { workspace = true }
@@ -31,10 +30,8 @@
3130redis = { workspace = true }
3231tracing = { workspace = true }
3332flate2 = { workspace = true }
34rand = { workspace = true }
3533bytes = { workspace = true }
3634jwt = { workspace = true }
37search = { workspace = true }
3835utoipa = { workspace = true }
3936maud = { workspace = true }
4037pulldown-cmark = { workspace = true }
Modified lib/git/todo.md View lib/git/todo.md
@@ -31,7 +31,7 @@
3131| 仓库/分支/标签/提交/PR/评论/星标等元数据 | KenApi 数据库 |
3232| LFS 锁 | KenApi 数据库(KenShell 无锁协议) |
3333| 页面渲染(maud HTML 片段、PDF 分页) | KenApi |
34| 定时任务、搜索索引、导入任务编排 | KenApi(`synchost`,见 §5) |
34| 定时任务 | 已移除 |
3535
3636`GitContext` 只描述「哪个仓库」:
3737
@@ -107,15 +107,16 @@
107107| 分支/标签删除的 hook 事件回写 | 不再触发(原 `sync` worker 负责) | KenShell 事件推送(§6) |
108108| 任意 ref 写入/删除(`PATCH/DELETE /refs/{name}`、`refs/notes/*`、`refs/pull/*`) | 仅支持 `refs/heads/*` | `CommandService/UpdateRef|DeleteRef` 泛化 |
109109| `GET /graphql` | **已移除**(前端未使用;字段逐步被 REST 覆盖) | 若要恢复:用 service 层重写,并补 `pack_stats`/`submodules`/`patch_generate` |
110| search 索引(`synchost` 的 search worker) | 仍按本地路径扫描,仓库已不在本地 | `SearchService` 或 `ListFiles/GetFile` 驱动索引 |
110| search 索引 | 已移除 | — |
111111
112112## 5. 进程与部署变化
113113
114114- **删除** `app/githost`:smart HTTP / SSH 由 KenShell 提供。`charts/core` 移除
115115 `githost` Deployment、Service、IngressRoute 与相关 values;`scripts/dev.*` 与 `justfile`
116116 同步移除。
117- **保留** `app/synchost`:只承载 import worker、search worker 与健康检查;
118 `git::sync::SyncWorker` 已删除。
117- **删除** `app/synchost`、`app/searchhost`、`app/webhook`、`app/runner`:
118 同步/导入、搜索索引、CI 与 webhook 均已移除;`git::sync::SyncWorker` 已删除。
119- 剩余进程:`apihost`、`im`、`migrate`、`openapi`。
119120- 克隆地址改为指向 KenShell:`git.http_clone_base_url`、`git.ssh_clone_*`;
120121 Helm 通过 `kenshell.*` values 注入
121122 `APP_GIT__KENSHELL_ENDPOINT`/`RPC_KEY`/`TIMEOUT_SECS`/`HTTP_CLONE_BASE_URL`/`SSH_CLONE_*`。
Modified lib/i18n/Cargo.toml View lib/i18n/Cargo.toml
@@ -4,7 +4,6 @@
44edition.workspace = true
55
66[dependencies]
7ci-protocol = { workspace = true }
87models = { workspace = true }
98sea-orm = { workspace = true }
109uuid = { workspace = true }
Deleted lib/import/src/handlers/cancel_import.rs
@@ -1,30 +0,0 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use jwt::Session;
4
5use crate::errors::ApiError;
6use crate::handlers::caller;
7use crate::service::{ImportJobDto, ImportJobPath, ImportService};
8
9#[utoipa::path(
10 post,
11 path = "/imports/{space_name}/{job_id}/cancel",
12 tag = "imports",
13 description = "取消排队中或执行中的导入任务;worker 在阶段边界停止并清除已保存的令牌。",
14 params(ImportJobPath),
15 responses(
16 (status = 200, description = "cancel import job", body = AppResponseData<ImportJobDto>)
17 )
18)]
19pub async fn cancel_import(
20 State(service): State<ImportService>,
21 session: Session,
22 Path(path): Path<ImportJobPath>,
23) -> Result<AppResponseData<ImportJobDto>, ApiError> {
24 let caller = caller(&session)?;
25 Ok(AppResponseData::ok(
26 service
27 .cancel(caller, &path.space_name, path.job_id)
28 .await?,
29 ))
30}
Deleted lib/import/src/handlers/create_import.rs
@@ -1,31 +0,0 @@
1use axum::Json;
2use axum::extract::{Path, State};
3use export::rest::AppResponseData;
4use jwt::Session;
5
6use crate::errors::ApiError;
7use crate::handlers::caller;
8use crate::service::{CreateImportCmd, ImportJobDto, ImportService, ImportSpacePath};
9
10#[utoipa::path(
11 post,
12 path = "/imports/{space_name}",
13 tag = "imports",
14 description = "创建导入任务:先探测源仓库并在空间下创建目标仓库,再由 synchost 异步镜像抓取全部引用并同步元数据;仅空间所有者可操作。令牌加密保存在任务上,任务结束即清除。",
15 params(ImportSpacePath),
16 request_body = CreateImportCmd,
17 responses(
18 (status = 200, description = "create import job", body = AppResponseData<ImportJobDto>)
19 )
20)]
21pub async fn create_import(
22 State(service): State<ImportService>,
23 session: Session,
24 Path(path): Path<ImportSpacePath>,
25 Json(cmd): Json<CreateImportCmd>,
26) -> Result<AppResponseData<ImportJobDto>, ApiError> {
27 let caller = caller(&session)?;
28 Ok(AppResponseData::ok(
29 service.create(caller, &path.space_name, cmd).await?,
30 ))
31}
Deleted lib/import/src/handlers/get_import.rs
@@ -1,28 +0,0 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use jwt::Session;
4
5use crate::errors::ApiError;
6use crate::handlers::caller;
7use crate::service::{ImportJobDto, ImportJobPath, ImportService};
8
9#[utoipa::path(
10 get,
11 path = "/imports/{space_name}/{job_id}",
12 tag = "imports",
13 description = "查询单个导入任务的状态、阶段、统计与失败原因。",
14 params(ImportJobPath),
15 responses(
16 (status = 200, description = "get import job", body = AppResponseData<ImportJobDto>)
17 )
18)]
19pub async fn get_import(
20 State(service): State<ImportService>,
21 session: Session,
22 Path(path): Path<ImportJobPath>,
23) -> Result<AppResponseData<ImportJobDto>, ApiError> {
24 let caller = caller(&session)?;
25 Ok(AppResponseData::ok(
26 service.get(caller, &path.space_name, path.job_id).await?,
27 ))
28}
Deleted lib/import/src/handlers/list_imports.rs
@@ -1,29 +0,0 @@
1use axum::extract::{Path, Query, State};
2use export::rest::AppResponseData;
3use jwt::Session;
4
5use crate::errors::ApiError;
6use crate::handlers::caller;
7use crate::service::{ImportJobPage, ImportService, ImportSpacePath, ListImportQuery};
8
9#[utoipa::path(
10 get,
11 path = "/imports/{space_name}",
12 tag = "imports",
13 description = "分页列出空间内的导入任务;空间所有者与平台管理员可见全部,其余成员仅见自己发起的任务。",
14 params(ImportSpacePath, ListImportQuery),
15 responses(
16 (status = 200, description = "list import jobs", body = AppResponseData<ImportJobPage>)
17 )
18)]
19pub async fn list_imports(
20 State(service): State<ImportService>,
21 session: Session,
22 Path(path): Path<ImportSpacePath>,
23 Query(query): Query<ListImportQuery>,
24) -> Result<AppResponseData<ImportJobPage>, ApiError> {
25 let caller = caller(&session)?;
26 Ok(AppResponseData::ok(
27 service.list(caller, &path.space_name, query).await?,
28 ))
29}
Deleted lib/import/src/handlers/mod.rs
@@ -1,16 +0,0 @@
1pub mod cancel_import;
2pub mod create_import;
3pub mod get_import;
4pub mod list_imports;
5pub mod probe_import;
6pub mod requeue_imports;
7pub mod retry_import;
8
9use jwt::{Session, SessionUser};
10use uuid::Uuid;
11
12use crate::errors::ApiError;
13
14pub fn caller(session: &Session) -> Result<i64, ApiError> {
15 session.uid().ok_or_else(ApiError::unauthorized)
16}
Deleted lib/import/src/handlers/probe_import.rs
@@ -1,31 +0,0 @@
1use axum::Json;
2use axum::extract::{Path, State};
3use export::rest::AppResponseData;
4use jwt::Session;
5
6use crate::errors::ApiError;
7use crate::handlers::caller;
8use crate::service::{ImportProbeDto, ImportService, ImportSpacePath, ProbeImportCmd};
9
10#[utoipa::path(
11 post,
12 path = "/imports/{space_name}/probe",
13 tag = "imports",
14 description = "探测 GitHub / GitLab / Gitea 源仓库,返回名称、默认分支、私有性与体积预览,不落库也不创建仓库。",
15 params(ImportSpacePath),
16 request_body = ProbeImportCmd,
17 responses(
18 (status = 200, description = "probe import source", body = AppResponseData<ImportProbeDto>)
19 )
20)]
21pub async fn probe_import(
22 State(service): State<ImportService>,
23 session: Session,
24 Path(path): Path<ImportSpacePath>,
25 Json(cmd): Json<ProbeImportCmd>,
26) -> Result<AppResponseData<ImportProbeDto>, ApiError> {
27 let caller = caller(&session)?;
28 Ok(AppResponseData::ok(
29 service.probe(caller, &path.space_name, cmd).await?,
30 ))
31}
Deleted lib/import/src/handlers/requeue_imports.rs
@@ -1,11 +0,0 @@
1use axum::extract::State;
2use export::rest::AppResponseData;
3
4use crate::errors::ApiError;
5use crate::service::{ImportService, RequeueResultDto};
6
7pub async fn requeue_imports(
8 State(service): State<ImportService>,
9) -> Result<AppResponseData<RequeueResultDto>, ApiError> {
10 Ok(AppResponseData::ok(service.requeue_pending().await?))
11}
Deleted lib/import/src/handlers/retry_import.rs
@@ -1,33 +0,0 @@
1use axum::Json;
2use axum::extract::{Path, State};
3use export::rest::AppResponseData;
4use jwt::Session;
5
6use crate::errors::ApiError;
7use crate::handlers::caller;
8use crate::service::{ImportJobDto, ImportJobPath, ImportService, RetryImportCmd};
9
10#[utoipa::path(
11 post,
12 path = "/imports/{space_name}/{job_id}/retry",
13 tag = "imports",
14 description = "重试已失败或已取消的导入任务;令牌在终态已被清除,私有仓库需在此重新提供。",
15 params(ImportJobPath),
16 request_body = RetryImportCmd,
17 responses(
18 (status = 200, description = "retry import job", body = AppResponseData<ImportJobDto>)
19 )
20)]
21pub async fn retry_import(
22 State(service): State<ImportService>,
23 session: Session,
24 Path(path): Path<ImportJobPath>,
25 Json(cmd): Json<RetryImportCmd>,
26) -> Result<AppResponseData<ImportJobDto>, ApiError> {
27 let caller = caller(&session)?;
28 Ok(AppResponseData::ok(
29 service
30 .retry(caller, &path.space_name, path.job_id, cmd)
31 .await?,
32 ))
33}
Deleted lib/import/src/providers/gitea.rs
@@ -1,48 +0,0 @@
1use crate::errors::ImportError;
2
3use super::{
4 ImportProvider, ProbeInfo, ProbeRequest, api_base, get_json, http_client, json_bool, json_i64,
5 json_string, source_path,
6};
7
8pub async fn probe(request: ProbeRequest<'_>) -> Result<ProbeInfo, ImportError> {
9 let client = http_client(request.config)?;
10 let base = api_base(ImportProvider::Gitea, request.base_url, request.config)?;
11 let (path, fallback_name) = source_path(ImportProvider::Gitea, request.source)?;
12 let url = format!("{base}/repos/{path}");
13 let value = get_json(&client, &url, ImportProvider::Gitea, request.token).await?;
14 let clone_url = json_string(&value, &["clone_url"]).ok_or_else(|| {
15 ImportError::Upstream("source forge did not return a clone url".to_string())
16 })?;
17 let owner = json_string(&value, &["owner", "login"])
18 .unwrap_or_else(|| path.split('/').next().unwrap_or_default().to_string());
19 let credential_username = match request.token.filter(|token| !token.trim().is_empty()) {
20 Some(token) => {
21 let me = get_json(
22 &client,
23 &format!("{base}/user"),
24 ImportProvider::Gitea,
25 Some(token),
26 )
27 .await
28 .ok()
29 .and_then(|value| json_string(&value, &["login"]))
30 .filter(|login| !login.is_empty());
31 Some(me.unwrap_or_else(|| owner.clone()))
32 }
33 None => None,
34 };
35 Ok(ProbeInfo {
36 provider: ImportProvider::Gitea,
37 owner,
38 name: json_string(&value, &["name"]).unwrap_or(fallback_name),
39 clone_url,
40 default_branch: json_string(&value, &["default_branch"]).unwrap_or_else(|| "main".into()),
41 is_private: json_bool(&value, &["private"]).unwrap_or(false),
42 description: json_string(&value, &["description"]),
43 size_bytes: json_i64(&value, &["size"]).map(|kb| kb.saturating_mul(1024)),
44 owner_external_id: json_string(&value, &["owner", "id"]),
45 owner_avatar_url: json_string(&value, &["owner", "avatar_url"]),
46 credential_username,
47 })
48}
Deleted lib/import/src/providers/github.rs
@@ -1,37 +0,0 @@
1use crate::errors::ImportError;
2
3use super::{
4 ImportProvider, ProbeInfo, ProbeRequest, api_base, get_json, http_client, json_bool, json_i64,
5 json_string, source_path,
6};
7
8pub async fn probe(request: ProbeRequest<'_>) -> Result<ProbeInfo, ImportError> {
9 let client = http_client(request.config)?;
10 let base = api_base(ImportProvider::Github, request.base_url, request.config)?;
11 let (path, fallback_name) = source_path(ImportProvider::Github, request.source)?;
12 let url = format!("{base}/repos/{path}");
13 let value = get_json(&client, &url, ImportProvider::Github, request.token).await?;
14 let clone_url = json_string(&value, &["clone_url"]).ok_or_else(|| {
15 ImportError::Upstream("source forge did not return a clone url".to_string())
16 })?;
17 let owner = json_string(&value, &["owner", "login"])
18 .unwrap_or_else(|| path.split('/').next().unwrap_or_default().to_string());
19 let has_token = request.token.is_some_and(|token| !token.trim().is_empty());
20 Ok(ProbeInfo {
21 provider: ImportProvider::Github,
22 owner,
23 name: json_string(&value, &["name"]).unwrap_or(fallback_name),
24 clone_url,
25 default_branch: json_string(&value, &["default_branch"]).unwrap_or_else(|| "main".into()),
26 is_private: json_bool(&value, &["private"]).unwrap_or(false),
27 description: json_string(&value, &["description"]),
28 size_bytes: json_i64(&value, &["size"]).map(|kb| kb.saturating_mul(1024)),
29 owner_external_id: json_string(&value, &["owner", "id"]),
30 owner_avatar_url: json_string(&value, &["owner", "avatar_url"]),
31 credential_username: has_token.then(|| {
32 ImportProvider::Github
33 .default_credential_username()
34 .to_string()
35 }),
36 })
37}
Deleted lib/import/src/providers/gitlab.rs
@@ -1,44 +0,0 @@
1use crate::errors::ImportError;
2
3use super::{
4 ImportProvider, ProbeInfo, ProbeRequest, api_base, encode_project_path, get_json, http_client,
5 json_i64, json_string, source_path,
6};
7
8pub async fn probe(request: ProbeRequest<'_>) -> Result<ProbeInfo, ImportError> {
9 let client = http_client(request.config)?;
10 let base = api_base(ImportProvider::Gitlab, request.base_url, request.config)?;
11 let (path, fallback_name) = source_path(ImportProvider::Gitlab, request.source)?;
12 let url = format!(
13 "{base}/projects/{}?statistics=true",
14 encode_project_path(&path)
15 );
16 let value = get_json(&client, &url, ImportProvider::Gitlab, request.token).await?;
17 let clone_url = json_string(&value, &["http_url_to_repo"]).ok_or_else(|| {
18 ImportError::Upstream("source forge did not return a clone url".to_string())
19 })?;
20 let owner = json_string(&value, &["namespace", "full_path"])
21 .or_else(|| json_string(&value, &["namespace", "path"]))
22 .unwrap_or_else(|| path.split('/').next().unwrap_or_default().to_string());
23 let visibility = json_string(&value, &["visibility"]).unwrap_or_default();
24 let has_token = request.token.is_some_and(|token| !token.trim().is_empty());
25 Ok(ProbeInfo {
26 provider: ImportProvider::Gitlab,
27 owner,
28 name: json_string(&value, &["name"])
29 .or_else(|| json_string(&value, &["path"]))
30 .unwrap_or(fallback_name),
31 clone_url,
32 default_branch: json_string(&value, &["default_branch"]).unwrap_or_else(|| "main".into()),
33 is_private: visibility == "private",
34 description: json_string(&value, &["description"]),
35 size_bytes: json_i64(&value, &["statistics", "disk_size_bytes"]),
36 owner_external_id: json_string(&value, &["namespace", "id"]),
37 owner_avatar_url: json_string(&value, &["namespace", "avatar_url"]),
38 credential_username: has_token.then(|| {
39 ImportProvider::Gitlab
40 .default_credential_username()
41 .to_string()
42 }),
43 })
44}
Deleted lib/import/src/providers/mod.rs
@@ -1,326 +0,0 @@
1pub mod gitea;
2pub mod github;
3pub mod gitlab;
4
5use std::time::Duration;
6
7use config::import::ImportConfig;
8use reqwest::{Client, header};
9use serde::{Deserialize, Serialize};
10use utoipa::ToSchema;
11
12use crate::errors::ImportError;
13
14const MAX_RETRY_ATTEMPTS: u32 = 2;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
17#[serde(rename_all = "snake_case")]
18pub enum ImportProvider {
19 Github,
20 Gitlab,
21 Gitea,
22}
23
24impl ImportProvider {
25 #[must_use]
26 pub fn as_str(self) -> &'static str {
27 match self {
28 Self::Github => "github",
29 Self::Gitlab => "gitlab",
30 Self::Gitea => "gitea",
31 }
32 }
33
34 pub fn parse(value: &str) -> Result<Self, ImportError> {
35 match value.trim().to_ascii_lowercase().as_str() {
36 "github" => Ok(Self::Github),
37 "gitlab" => Ok(Self::Gitlab),
38 "gitea" => Ok(Self::Gitea),
39 other => Err(ImportError::Internal(format!(
40 "unknown import provider `{other}`"
41 ))),
42 }
43 }
44
45 #[must_use]
46 pub fn default_root(self) -> &'static str {
47 match self {
48 Self::Github => "https://github.com",
49 Self::Gitlab => "https://gitlab.com",
50 Self::Gitea => "https://gitea.com",
51 }
52 }
53
54 #[must_use]
55 pub fn default_credential_username(self) -> &'static str {
56 match self {
57 Self::Github => "x-access-token",
58 Self::Gitlab => "oauth2",
59 Self::Gitea => "git",
60 }
61 }
62}
63
64#[derive(Debug, Clone, Serialize, ToSchema)]
65pub struct ProbeInfo {
66 pub provider: ImportProvider,
67 pub owner: String,
68 pub name: String,
69 pub clone_url: String,
70 pub default_branch: String,
71 pub is_private: bool,
72 pub description: Option<String>,
73 pub size_bytes: Option<i64>,
74 pub owner_external_id: Option<String>,
75 pub owner_avatar_url: Option<String>,
76 pub credential_username: Option<String>,
77}
78
79pub struct ProbeRequest<'a> {
80 pub provider: ImportProvider,
81 pub source: &'a str,
82 pub base_url: Option<&'a str>,
83 pub token: Option<&'a str>,
84 pub config: &'a ImportConfig,
85}
86
87pub async fn probe(request: ProbeRequest<'_>) -> Result<ProbeInfo, ImportError> {
88 match request.provider {
89 ImportProvider::Github => github::probe(request).await,
90 ImportProvider::Gitlab => gitlab::probe(request).await,
91 ImportProvider::Gitea => gitea::probe(request).await,
92 }
93}
94
95pub(crate) fn http_client(config: &ImportConfig) -> Result<Client, ImportError> {
96 Client::builder()
97 .timeout(Duration::from_secs(config.api_timeout_secs.max(1)))
98 .danger_accept_invalid_certs(config.allow_insecure_tls)
99 .user_agent("gitdata-import/1.0")
100 .build()
101 .map_err(|e| ImportError::Internal(format!("build import http client: {e}")))
102}
103
104pub(crate) fn host_of(url: &str) -> Option<String> {
105 let rest = url.split_once("://")?.1;
106 let authority = rest.split(['/', '?', '#']).next()?;
107 let without_user = authority.rsplit('@').next()?;
108 let host = strip_brackets_and_port(without_user);
109 if host.is_empty() {
110 None
111 } else {
112 Some(host.to_ascii_lowercase())
113 }
114}
115
116fn strip_brackets_and_port(host: &str) -> String {
117 if host.starts_with('[')
118 && let Some(end) = host.find(']')
119 {
120 return host[1..end].to_string();
121 }
122 if host.matches(':').count() == 1
123 && let Some(idx) = host.rfind(':')
124 {
125 let (h, port) = host.split_at(idx);
126 let port = &port[1..];
127 if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) {
128 return h.to_string();
129 }
130 }
131 host.to_string()
132}
133
134pub fn ensure_url_allowed(url: &str, config: &ImportConfig) -> Result<(), ImportError> {
135 let trimmed = url.trim();
136 if trimmed.is_empty() || trimmed.starts_with('-') {
137 return Err(ImportError::BadRequest("source url is empty".to_string()));
138 }
139 if trimmed.chars().any(|c| c.is_control() || c.is_whitespace()) {
140 return Err(ImportError::BadRequest(
141 "source url contains control characters".to_string(),
142 ));
143 }
144 let Some((scheme, rest)) = trimmed.split_once("://") else {
145 return Err(ImportError::BadRequest(format!(
146 "source url `{trimmed}` must include http:// or https://"
147 )));
148 };
149 let scheme = scheme.to_ascii_lowercase();
150 if scheme != "http" && scheme != "https" {
151 return Err(ImportError::BadRequest(format!(
152 "source url scheme `{scheme}` is not allowed, use http or https"
153 )));
154 }
155 if let Some(userinfo) = rest
156 .split(['/', '?', '#'])
157 .next()
158 .and_then(|a| a.rsplit_once('@'))
159 && userinfo.0.contains(':')
160 {
161 return Err(ImportError::BadRequest(
162 "source url must not embed credentials".to_string(),
163 ));
164 }
165 let Some(host) = host_of(trimmed) else {
166 return Err(ImportError::BadRequest(format!(
167 "source url `{trimmed}` has no host"
168 )));
169 };
170 if git::share::validate::validate_remote_url(trimmed).is_err() && !config.host_allowed(&host) {
171 return Err(ImportError::BadRequest(format!(
172 "source host `{host}` is not allowed; add it to import.allowed_hosts for self-hosted forges"
173 )));
174 }
175 Ok(())
176}
177
178pub(crate) fn api_base(
179 provider: ImportProvider,
180 base_url: Option<&str>,
181 config: &ImportConfig,
182) -> Result<String, ImportError> {
183 let root = match base_url.map(str::trim).filter(|value| !value.is_empty()) {
184 Some(value) => value.trim_end_matches('/').to_string(),
185 None => provider.default_root().to_string(),
186 };
187 ensure_url_allowed(&root, config)?;
188 let host = host_of(&root).unwrap_or_default();
189 Ok(match provider {
190 ImportProvider::Github if host == "github.com" || host == "api.github.com" => {
191 "https://api.github.com".to_string()
192 }
193 ImportProvider::Github => format!("{root}/api/v3"),
194 ImportProvider::Gitlab => format!("{root}/api/v4"),
195 ImportProvider::Gitea => format!("{root}/api/v1"),
196 })
197}
198
199pub(crate) fn source_path(
200 provider: ImportProvider,
201 source: &str,
202) -> Result<(String, String), ImportError> {
203 let trimmed = source.trim().trim_end_matches('/');
204 if trimmed.is_empty() {
205 return Err(ImportError::BadRequest("source is empty".to_string()));
206 }
207 let without_host = match trimmed.split_once("://") {
208 Some((_, rest)) => match rest.split_once('/') {
209 Some((_, path)) => path,
210 None => "",
211 },
212 None => trimmed,
213 };
214 let path = without_host.split(['?', '#']).next().unwrap_or_default();
215 let path = path.trim_matches('/');
216 let path = path.strip_suffix(".git").unwrap_or(path);
217 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
218 if segments.len() < 2 {
219 return Err(ImportError::BadRequest(format!(
220 "cannot read owner and repository from source `{source}`; use `owner/repo` or a full repository url"
221 )));
222 }
223 let max_segments = match provider {
224 ImportProvider::Gitlab => 20,
225 _ => 2,
226 };
227 if segments.len() > max_segments {
228 return Err(ImportError::BadRequest(format!(
229 "source `{source}` has too many path segments for {}",
230 provider.as_str()
231 )));
232 }
233 if segments
234 .iter()
235 .any(|s| s.chars().any(|c| c.is_control() || c.is_whitespace()))
236 {
237 return Err(ImportError::BadRequest(format!(
238 "source `{source}` contains invalid characters"
239 )));
240 }
241 let full = segments.join("/");
242 let name = segments.last().copied().unwrap_or_default().to_string();
243 Ok((full, name))
244}
245
246pub(crate) fn encode_project_path(path: &str) -> String {
247 path.replace('/', "%2F")
248}
249
250pub(crate) async fn get_json(
251 client: &Client,
252 url: &str,
253 provider: ImportProvider,
254 token: Option<&str>,
255) -> Result<serde_json::Value, ImportError> {
256 let mut attempt: u32 = 0;
257 loop {
258 let mut request = client.get(url).header(header::ACCEPT, "application/json");
259 if let Some(token) = token.filter(|value| !value.trim().is_empty()) {
260 request = match provider {
261 ImportProvider::Github => request
262 .header(header::AUTHORIZATION, format!("Bearer {token}"))
263 .header("X-GitHub-Api-Version", "2022-11-28"),
264 ImportProvider::Gitlab => request.header("PRIVATE-TOKEN", token),
265 ImportProvider::Gitea => {
266 request.header(header::AUTHORIZATION, format!("token {token}"))
267 }
268 };
269 }
270 let response = request.send().await?;
271 let status = response.status();
272 if status.is_success() {
273 let text = response.text().await?;
274 return serde_json::from_str(&text).map_err(|e| {
275 ImportError::Upstream(format!("source forge returned invalid json: {e}"))
276 });
277 }
278 let body = response.text().await.unwrap_or_default();
279 let detail: String = body.chars().take(300).collect();
280 let code = status.as_u16();
281 if code == 401 || code == 403 {
282 return Err(ImportError::BadRequest(format!(
283 "source forge rejected the credentials ({code}): {detail}"
284 )));
285 }
286 if code == 404 {
287 return Err(ImportError::NotFound);
288 }
289 if (code == 429 || (500..=599).contains(&code)) && attempt < MAX_RETRY_ATTEMPTS {
290 attempt += 1;
291 tokio::time::sleep(Duration::from_secs(u64::from(attempt) * 2)).await;
292 continue;
293 }
294 return Err(ImportError::Upstream(format!(
295 "source forge returned {code}: {detail}"
296 )));
297 }
298}
299
300pub(crate) fn json_string(value: &serde_json::Value, path: &[&str]) -> Option<String> {
301 let mut current = value;
302 for key in path {
303 current = current.get(key)?;
304 }
305 match current {
306 serde_json::Value::String(text) if !text.is_empty() => Some(text.clone()),
307 serde_json::Value::Number(number) => Some(number.to_string()),
308 _ => None,
309 }
310}
311
312pub(crate) fn json_bool(value: &serde_json::Value, path: &[&str]) -> Option<bool> {
313 let mut current = value;
314 for key in path {
315 current = current.get(key)?;
316 }
317 current.as_bool()
318}
319
320pub(crate) fn json_i64(value: &serde_json::Value, path: &[&str]) -> Option<i64> {
321 let mut current = value;
322 for key in path {
323 current = current.get(key)?;
324 }
325 current.as_i64()
326}
Deleted lib/import/src/share/guard.rs

Diff too large to display.

Deleted lib/import/src/share/mod.rs

Diff too large to display.

Deleted lib/import/src/errors.rs
@@ -1,117 +0,0 @@
1use std::fmt;
2
3#[derive(Debug)]
4pub enum ImportError {
5 Disabled,
6 Canceled,
7 BadRequest(String),
8 Unauthorized,
9 Forbidden,
10 NotFound,
11 Conflict(String),
12 RateLimited(String),
13 Upstream(String),
14 Internal(String),
15}
16
17impl ImportError {
18 #[must_use]
19 pub fn is_permanent(&self) -> bool {
20 matches!(
21 self,
22 Self::Disabled
23 | Self::Canceled
24 | Self::BadRequest(_)
25 | Self::Unauthorized
26 | Self::Forbidden
27 | Self::NotFound
28 | Self::Conflict(_)
29 )
30 }
31}
32
33impl fmt::Display for ImportError {
34 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35 match self {
36 Self::Disabled => write!(f, "import is disabled"),
37 Self::Canceled => write!(f, "import canceled"),
38 Self::BadRequest(message) => write!(f, "{message}"),
39 Self::Unauthorized => write!(f, "unauthorized"),
40 Self::Forbidden => write!(f, "forbidden"),
41 Self::NotFound => write!(f, "not found"),
42 Self::Conflict(message) => write!(f, "{message}"),
43 Self::RateLimited(message) => write!(f, "{message}"),
44 Self::Upstream(message) => write!(f, "{message}"),
45 Self::Internal(message) => write!(f, "{message}"),
46 }
47 }
48}
49
50impl std::error::Error for ImportError {}
51
52export::db_error_conversions!(ImportError);
53export::export_error_conversion!(ImportError);
54export::json_payload_conversion!(ImportError);
55
56impl From<git::share::errors::GitError> for ImportError {
57 fn from(error: git::share::errors::GitError) -> Self {
58 use git::share::errors::GitError as G;
59 match error {
60 G::InvalidArgument(msg) => Self::BadRequest(msg),
61 G::NotFound => Self::NotFound,
62 G::Unauthorized => Self::Unauthorized,
63 G::Forbidden => Self::Forbidden,
64 G::Conflict(msg) => Self::Conflict(msg),
65 G::Cache(_) | G::Git(_) | G::Internal(_) | G::Unimplemented(_) => {
66 Self::Internal(error.to_string())
67 }
68 }
69 }
70}
71
72impl From<reqwest::Error> for ImportError {
73 fn from(error: reqwest::Error) -> Self {
74 Self::Upstream(format!("source forge request failed: {error}"))
75 }
76}
77
78export::api_error! {
79 log: "import",
80 pub enum ApiError {
81 BadRequest(400, "request failed validation"),
82 Unauthorized(401, "authentication required"),
83 Forbidden(403, "operation not allowed"),
84 NotFound(404, "resource not found"),
85 Conflict(409, "resource conflict"),
86 TooManyRequests(429, "rate limit exceeded"),
87 Internal(500, "internal server error"),
88 BadGateway(502, "upstream forge request failed"),
89 ServiceUnavailable(503, "import is unavailable"),
90 }
91}
92
93impl From<ImportError> for ApiError {
94 fn from(error: ImportError) -> Self {
95 match error {
96 ImportError::Disabled => Self::build(
97 503,
98 export::rest::AppResponseData::err(503, "import is unavailable")
99 .with_error_code("import_disabled"),
100 ),
101 ImportError::Canceled => Self::bad_request("import job canceled"),
102 ImportError::BadRequest(msg) => Self::bad_request(msg),
103 ImportError::Unauthorized => Self::unauthorized(),
104 ImportError::Forbidden => Self::forbidden(),
105 ImportError::NotFound => Self::not_found("import job or source not found"),
106 ImportError::Conflict(msg) => Self::conflict(msg),
107 ImportError::RateLimited(msg) => Self::rate_limited(msg),
108 ImportError::Upstream(msg) => Self::build(
109 502,
110 export::rest::AppResponseData::err(502, "upstream forge request failed")
111 .with_error(msg)
112 .with_error_code("import_upstream_failed"),
113 ),
114 ImportError::Internal(message) => Self::internal(message),
115 }
116 }
117}
Deleted lib/import/src/identity.rs
@@ -1,220 +0,0 @@
1use std::collections::HashMap;
2
3use models::git::git_commits;
4use models::users::{user, user_emails, user_external_identity};
5use sea_orm::{
6 ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QuerySelect, Set,
7};
8use time::OffsetDateTime;
9use uuid::Uuid;
10
11use crate::errors::ImportError;
12use crate::providers::ImportProvider;
13use crate::service::ImportOptions;
14
15struct Candidate {
16 login: String,
17 external_id: Option<String>,
18 name: Option<String>,
19 email: Option<String>,
20 avatar_url: Option<String>,
21}
22
23pub fn login_from_noreply_email(email: &str) -> Option<String> {
24 let (local, domain) = email.split_once('@')?;
25 let domain = domain.to_ascii_lowercase();
26 let known = domain.ends_with("users.noreply.github.com")
27 || domain.ends_with("users.noreply.gitlab.com")
28 || domain.starts_with("noreply.");
29 if !known {
30 return None;
31 }
32 let login = match local.split_once('+') {
33 Some((_, rest)) if !rest.is_empty() => rest,
34 _ => local,
35 };
36 let login = login.trim();
37 if login.is_empty() {
38 None
39 } else {
40 Some(login.to_string())
41 }
42}
43
44pub async fn harvest(
45 db: &DatabaseConnection,
46 provider: ImportProvider,
47 repo_id: Uuid,
48 options: &ImportOptions,
49) -> Result<(), ImportError> {
50 let mut candidates: Vec<Candidate> = Vec::new();
51 if !options.owner.trim().is_empty() {
52 candidates.push(Candidate {
53 login: options.owner.clone(),
54 external_id: options.owner_external_id.clone(),
55 name: None,
56 email: None,
57 avatar_url: options.owner_avatar_url.clone(),
58 });
59 }
60
61 let authors: Vec<(String, String)> = git_commits::Entity::find()
62 .select_only()
63 .column(git_commits::Column::AuthorName)
64 .column(git_commits::Column::AuthorEmail)
65 .filter(git_commits::Column::RepoId.eq(repo_id))
66 .filter(git_commits::Column::AuthorId.is_null())
67 .distinct()
68 .into_tuple()
69 .all(db)
70 .await?;
71 let mut seen: Vec<String> = Vec::new();
72 for (name, email) in authors {
73 let Some(login) = login_from_noreply_email(&email) else {
74 continue;
75 };
76 let lower = login.to_lowercase();
77 if seen.contains(&lower) {
78 continue;
79 }
80 seen.push(lower);
81 candidates.push(Candidate {
82 login,
83 external_id: None,
84 name: Some(name),
85 email: Some(email),
86 avatar_url: None,
87 });
88 }
89
90 let mut resolved: HashMap<String, i64> = HashMap::new();
91 for candidate in candidates {
92 if let Some((email, uid)) = upsert(db, provider, &candidate).await? {
93 resolved.insert(email, uid);
94 }
95 }
96 backfill_commit_authors(db, repo_id, &resolved).await?;
97 Ok(())
98}
99
100async fn resolve_user(
101 db: &DatabaseConnection,
102 candidate: &Candidate,
103 lower_login: &str,
104) -> Result<Option<i64>, ImportError> {
105 if let Some(email) = candidate
106 .email
107 .as_deref()
108 .map(str::trim)
109 .filter(|v| !v.is_empty())
110 {
111 let row = user_emails::Entity::find()
112 .filter(user_emails::Column::LowerEmail.eq(email.to_lowercase()))
113 .one(db)
114 .await?;
115 if let Some(row) = row {
116 return Ok(Some(row.user_id));
117 }
118 }
119 let row = user::Entity::find()
120 .filter(user::Column::LowerName.eq(lower_login))
121 .one(db)
122 .await?;
123 Ok(row.map(|row| row.uid))
124}
125
126async fn upsert(
127 db: &DatabaseConnection,
128 provider: ImportProvider,
129 candidate: &Candidate,
130) -> Result<Option<(String, i64)>, ImportError> {
131 let login = candidate.login.trim();
132 if login.is_empty() {
133 return Ok(None);
134 }
135 let lower_login = login.to_lowercase();
136 let lower_email = candidate
137 .email
138 .as_deref()
139 .map(str::trim)
140 .filter(|v| !v.is_empty())
141 .map(str::to_lowercase);
142 let uid = resolve_user(db, candidate, &lower_login).await?;
143 let now = OffsetDateTime::now_utc();
144 let existing = user_external_identity::Entity::find()
145 .filter(user_external_identity::Column::Provider.eq(provider.as_str()))
146 .filter(user_external_identity::Column::LowerLogin.eq(&lower_login))
147 .one(db)
148 .await?;
149
150 let resolved_uid = match existing {
151 Some(row) => {
152 let mut model = user_external_identity::ActiveModel {
153 id: Set(row.id),
154 updated_at: Set(now),
155 ..Default::default()
156 };
157 if let Some(value) = candidate.external_id.clone().filter(|v| !v.is_empty()) {
158 model.external_id = Set(Some(value));
159 }
160 if let Some(value) = candidate.name.clone().filter(|v| !v.is_empty()) {
161 model.name = Set(Some(value));
162 }
163 if let Some(value) = candidate.avatar_url.clone().filter(|v| !v.is_empty()) {
164 model.avatar_url = Set(Some(value));
165 }
166 if let Some(value) = lower_email.clone() {
167 model.email = Set(Some(value));
168 }
169 let resolved = row.user_id.or(uid);
170 if row.user_id.is_none() && resolved.is_some() {
171 model.user_id = Set(resolved);
172 }
173 model.update(db).await?;
174 resolved
175 }
176 None => {
177 let row = user_external_identity::ActiveModel {
178 id: Set(Uuid::now_v7()),
179 provider: Set(provider.as_str().to_string()),
180 external_id: Set(candidate.external_id.clone()),
181 lower_login: Set(lower_login),
182 login: Set(login.to_string()),
183 name: Set(candidate.name.clone()),
184 email: Set(lower_email.clone()),
185 avatar_url: Set(candidate.avatar_url.clone()),
186 user_id: Set(uid),
187 created_at: Set(now),
188 updated_at: Set(now),
189 }
190 .insert(db)
191 .await?;
192 row.user_id
193 }
194 };
195
196 Ok(match (candidate.email.clone(), resolved_uid, lower_email) {
197 (Some(email), Some(uid), Some(_)) if !email.trim().is_empty() => Some((email, uid)),
198 _ => None,
199 })
200}
201
202async fn backfill_commit_authors(
203 db: &DatabaseConnection,
204 repo_id: Uuid,
205 resolved: &HashMap<String, i64>,
206) -> Result<(), ImportError> {
207 for (email, uid) in resolved {
208 git_commits::Entity::update_many()
209 .col_expr(
210 git_commits::Column::AuthorId,
211 sea_orm::sea_query::Expr::value(*uid),
212 )
213 .filter(git_commits::Column::RepoId.eq(repo_id))
214 .filter(git_commits::Column::AuthorId.is_null())
215 .filter(git_commits::Column::AuthorEmail.eq(email.clone()))
216 .exec(db)
217 .await?;
218 }
219 Ok(())
220}
Deleted lib/import/src/lib.rs
@@ -1,17 +0,0 @@
1pub mod errors;
2pub mod handlers;
3pub mod identity;
4pub mod openapi;
5pub mod providers;
6pub mod routes;
7pub mod service;
8pub mod share;
9pub mod worker;
10
11pub use errors::{ApiError, ImportError};
12pub use providers::{ImportProvider, ProbeInfo};
13pub use service::{
14 CreateImportCmd, ImportJobDto, ImportJobPage, ImportJobState, ImportProbeDto, ImportService,
15 ImportStage, ImportStatsDto, ListImportQuery, ProbeImportCmd, RequeueResultDto, RetryImportCmd,
16};
17pub use worker::ImportWorker;
Deleted lib/import/src/openapi.rs
@@ -1,39 +0,0 @@
1use utoipa::OpenApi;
2
3use export::rest::{AppResponseData, NoData};
4
5#[derive(OpenApi)]
6#[openapi(
7 info(title = "gitdata import api", version = "1.0"),
8 paths(
9 crate::handlers::probe_import::probe_import,
10 crate::handlers::create_import::create_import,
11 crate::handlers::list_imports::list_imports,
12 crate::handlers::get_import::get_import,
13 crate::handlers::cancel_import::cancel_import,
14 crate::handlers::retry_import::retry_import,
15 ),
16 components(schemas(
17 AppResponseData<crate::service::ImportProbeDto>,
18 AppResponseData<crate::service::ImportJobDto>,
19 AppResponseData<crate::service::ImportJobPage>,
20 NoData,
21 crate::service::ImportProbeDto,
22 crate::service::ImportJobDto,
23 crate::service::ImportJobPage,
24 crate::service::ImportStatsDto,
25 crate::service::ImportJobState,
26 crate::service::ImportStage,
27 crate::service::ImportSpacePath,
28 crate::service::ImportJobPath,
29 crate::service::ProbeImportCmd,
30 crate::service::CreateImportCmd,
31 crate::service::RetryImportCmd,
32 crate::service::ListImportQuery,
33 crate::providers::ImportProvider,
34 )),
35 tags(
36 (name = "imports", description = "GitHub / GitLab / Gitea 仓库导入迁移")
37 )
38)]
39pub struct ImportApiDoc;
Deleted lib/import/src/routes.rs
@@ -1,32 +0,0 @@
1use axum::Router;
2use axum::routing::{get, post};
3
4use crate::handlers::cancel_import::cancel_import;
5use crate::handlers::create_import::create_import;
6use crate::handlers::get_import::get_import;
7use crate::handlers::list_imports::list_imports;
8use crate::handlers::probe_import::probe_import;
9use crate::handlers::requeue_imports::requeue_imports;
10use crate::handlers::retry_import::retry_import;
11use crate::service::ImportService;
12use crate::share::guard::import_guard;
13
14pub fn router(service: ImportService) -> Router<ImportService> {
15 Router::new()
16 .route(
17 "/imports/{space_name}",
18 post(create_import).get(list_imports),
19 )
20 .route("/imports/{space_name}/probe", post(probe_import))
21 .route("/imports/{space_name}/{job_id}", get(get_import))
22 .route("/imports/{space_name}/{job_id}/cancel", post(cancel_import))
23 .route("/imports/{space_name}/{job_id}/retry", post(retry_import))
24 .route_layer(axum::middleware::from_fn_with_state(
25 service.clone(),
26 import_guard,
27 ))
28}
29
30pub fn internal_router() -> Router<ImportService> {
31 Router::new().route("/imports/requeue", post(requeue_imports))
32}
Deleted lib/import/src/service.rs

Diff too large to display.

Deleted lib/import/src/worker.rs

Diff too large to display.

Deleted lib/import/Cargo.toml
@@ -1,27 +0,0 @@
1[package]
2name = "import"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7async-trait = { workspace = true }
8auth = { workspace = true }
9axum = { workspace = true }
10cache = { workspace = true }
11config = { workspace = true }
12export = { workspace = true }
13git = { workspace = true }
14kenshell = { workspace = true }
15models = { workspace = true }
16reqwest = { workspace = true }
17sea-orm = { workspace = true }
18serde = { workspace = true, features = ["derive"] }
19serde_json = { workspace = true }
20jwt = { workspace = true }
21thiserror = { workspace = true }
22time = { workspace = true }
23tokio = { workspace = true, features = ["fs", "process", "time", "sync", "rt", "macros"] }
24tokio-util = { workspace = true }
25tracing = { workspace = true }
26utoipa = { workspace = true }
27uuid = { workspace = true }
No newline at end of file
Modified lib/issues/src/services/comments.rs View lib/issues/src/services/comments.rs
@@ -281,7 +281,6 @@
281281 let user_map = self.load_user_briefs(&[inserted.poster_id]).await?;
282282 let poster = user_map.get(&inserted.poster_id).cloned();
283283
284 self.queue_search_issue(issue_id).await?;
285284
286285 Ok(CommentDto {
287286 id: inserted.id,
@@ -519,7 +518,6 @@
519518 let user_map = self.load_user_briefs(&[updated.poster_id]).await?;
520519 let poster = user_map.get(&updated.poster_id).cloned();
521520
522 self.queue_search_issue(issue_id).await?;
523521
524522 Ok(CommentDto {
525523 id: updated.id,
@@ -584,7 +582,6 @@
584582 })
585583 .await?;
586584
587 self.queue_search_issue(issue_id).await?;
588585 Ok(())
589586 }
590587}
Modified lib/issues/src/services/issues.rs View lib/issues/src/services/issues.rs
@@ -693,7 +693,6 @@
693693 }
694694 }
695695
696 self.queue_search_issue(inserted.id).await?;
697696 self.build_issue_dto(caller, inserted).await
698697 }
699698
@@ -1130,7 +1129,6 @@
11301129 })
11311130 .await?;
11321131
1133 self.queue_search_issue(updated.id).await?;
11341132 self.build_issue_dto(caller, updated).await
11351133 }
11361134}
@@ -1249,7 +1247,6 @@
12491247 }
12501248 }
12511249
1252 self.queue_search_issue(issue_id).await?;
12531250 Ok(())
12541251 }
12551252
@@ -1304,7 +1301,6 @@
13041301 })
13051302 .await?;
13061303
1307 self.queue_search_issue(updated.id).await?;
13081304 self.build_issue_dto(caller, updated).await
13091305 }
13101306}
Modified lib/issues/src/services/labels.rs View lib/issues/src/services/labels.rs
@@ -311,16 +311,6 @@
311311 am.updated_at = Set(Some(now));
312312
313313 let updated = am.update(self.db().write()).await?;
314 let issue_ids: Vec<Uuid> = issue_relation_label::Entity::find()
315 .filter(issue_relation_label::Column::LabelId.eq(label_id))
316 .all(self.db().read())
317 .await?
318 .into_iter()
319 .map(|row| row.issue_id)
320 .collect();
321 for issue_id in issue_ids {
322 self.queue_search_issue(issue_id).await?;
323 }
324314 Ok(LabelDto::from(updated))
325315 }
326316
@@ -339,33 +329,6 @@
339329 .one(self.db().read())
340330 .await?
341331 .ok_or(IssueError::NotFound)?;
342 let issue_ids: Vec<Uuid> = issue_relation_label::Entity::find()
343 .filter(issue_relation_label::Column::LabelId.eq(label_id))
344 .all(self.db().read())
345 .await?
346 .into_iter()
347 .map(|row| row.issue_id)
348 .collect();
349
350 self.db()
351 .primary()
352 .transaction::<_, (), IssueError>(|txn| {
353 Box::pin(async move {
354 issue_relation_label::Entity::delete_many()
355 .filter(issue_relation_label::Column::LabelId.eq(label_id))
356 .exec(txn)
357 .await?;
358 issue_label::Entity::delete_by_id(label_id)
359 .exec(txn)
360 .await?;
361 Ok(())
362 })
363 })
364 .await?;
365
366 for issue_id in issue_ids {
367 self.queue_search_issue(issue_id).await?;
368 }
369332 Ok(())
370333 }
371334
@@ -457,7 +420,6 @@
457420 })
458421 .await?;
459422
460 self.queue_search_issue(issue_id).await?;
461423 Ok(())
462424 }
463425
Modified lib/issues/src/services/service.rs View lib/issues/src/services/service.rs
@@ -55,11 +55,6 @@
5555 &self.state.database
5656 }
5757
58 pub async fn queue_search_issue(&self, issue_id: Uuid) -> Result<(), IssueError> {
59 search::outbox::enqueue_issue(self.db().write(), issue_id).await?;
60 Ok(())
61 }
62
6358 pub async fn load_user_briefs(
6459 &self,
6560 uids: &[i64],
@@ -265,4 +260,3 @@
265260 .map_err(|error| IssueError::Internal(format!("email send failed: {error}")))
266261 }
267262}
268
Modified lib/issues/src/share/authz.rs View lib/issues/src/share/authz.rs
@@ -1,6 +1,5 @@
11use models::space::spaces;
22use space::share::authz::{MANAGE_CHANNELS, effective_permissions};
3use uuid::Uuid;
43
54use crate::errors::IssueError;
65use crate::services::service::IssueService;
Modified lib/issues/Cargo.toml View lib/issues/Cargo.toml
@@ -21,4 +21,3 @@
2121uuid = { workspace = true }
2222time = { workspace = true }
2323tracing = { workspace = true }
24search = { workspace = true }
Modified lib/models/src/chat/presence_states.rs View lib/models/src/chat/presence_states.rs
@@ -2,7 +2,6 @@
22use sea_orm::entity::prelude::*;
33use serde::{Deserialize, Serialize};
44use time::OffsetDateTime;
5use uuid::Uuid;
65
76#[derive(
87 Debug,
Deleted lib/models/src/ci/actions_cache.rs

Diff too large to display.

Deleted lib/models/src/ci/actions_identity.rs

Diff too large to display.

Deleted lib/models/src/ci/actions_schedule.rs

Diff too large to display.

Deleted lib/models/src/ci/ci_deployment.rs

Diff too large to display.

Deleted lib/models/src/ci/ci_environment.rs

Diff too large to display.

Deleted lib/models/src/ci/ci_log_chunk.rs

Diff too large to display.

Deleted lib/models/src/ci/ci_secret.rs

Diff too large to display.

Deleted lib/models/src/ci/environment_review.rs

Diff too large to display.

Deleted lib/models/src/ci/job_token.rs

Diff too large to display.

Deleted lib/models/src/ci/mod.rs

Diff too large to display.

Deleted lib/models/src/ci/runner.rs

Diff too large to display.

Deleted lib/models/src/ci/runner_token.rs

Diff too large to display.

Deleted lib/models/src/ci/variable.rs

Diff too large to display.

Deleted lib/models/src/ci/workflow_artifact.rs

Diff too large to display.

Deleted lib/models/src/ci/workflow_counter.rs

Diff too large to display.

Deleted lib/models/src/ci/workflow_job.rs

Diff too large to display.

Deleted lib/models/src/ci/workflow_run.rs

Diff too large to display.

Deleted lib/models/src/ci/workflow_step.rs

Diff too large to display.

Deleted lib/models/src/import/job.rs

Diff too large to display.

Deleted lib/models/src/import/mod.rs

Diff too large to display.

Modified lib/models/src/repo/repos.rs View lib/models/src/repo/repos.rs
@@ -52,10 +52,7 @@
5252 /// 创建接口拒绝执行。
5353 #[sea_orm(default_value = "true")]
5454 pub allow_pull_requests: bool,
55 /// 流水线页签是否公开展示;关闭后仅空间成员可见。
56 #[sea_orm(default_value = "true")]
57 pub pipelines_public: bool,
58 /// 中间页签顺序(提交/合并请求/分支/流水线/发布);缺省 = 站点默认顺序。
55 /// 中间页签顺序(提交/合并请求/分支/发布);缺省 = 站点默认顺序。
5956 #[sea_orm(column_type = "JsonBinary")]
6057 pub tab_order: Option<sea_orm::JsonValue>,
6158 #[sea_orm(belongs_to, from = "dir_id", to = "id")]
Deleted lib/models/src/search/index_job.rs

Diff too large to display.

Deleted lib/models/src/search/mod.rs

Diff too large to display.

Deleted lib/models/src/search/repo_state.rs

Diff too large to display.

Modified lib/models/src/users/user.rs View lib/models/src/users/user.rs
@@ -2,7 +2,6 @@
22use sea_orm::{EntityTrait, QueryFilter, Select};
33use serde::{Deserialize, Serialize};
44use time::OffsetDateTime;
5use uuid::Uuid;
65
76#[derive(
87 Debug,
Modified lib/models/src/users/user_preferences.rs View lib/models/src/users/user_preferences.rs
@@ -1,6 +1,5 @@
11use sea_orm::entity::prelude::*;
22use time::OffsetDateTime;
3use uuid::Uuid;
43
54#[sea_orm::model]
65#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
Modified lib/models/src/users/user_profiles.rs View lib/models/src/users/user_profiles.rs
@@ -1,6 +1,5 @@
11use sea_orm::entity::prelude::*;
22use time::{Date, OffsetDateTime};
3use uuid::Uuid;
43
54#[sea_orm::model]
65#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
Deleted lib/models/src/webhook/mod.rs

Diff too large to display.

Deleted lib/models/src/webhook/repo_webhook_delivery.rs

Diff too large to display.

Deleted lib/models/src/webhook/repo_webhooks.rs

Diff too large to display.

Deleted lib/models/src/webhook/webhook_deliveries.rs

Diff too large to display.

Deleted lib/models/src/webhook/webhooks.rs

Diff too large to display.

Modified lib/models/src/lib.rs View lib/models/src/lib.rs
@@ -1,17 +1,13 @@
11pub mod chat;
2pub mod ci;
32pub mod common;
43pub mod dir;
54pub mod git;
6pub mod import;
75pub mod issue;
86pub mod migrate;
97pub mod products;
108pub mod pull_request;
119pub mod repo;
1210pub mod room;
13pub mod search;
1411pub mod space;
1512pub mod system;
1613pub mod users;
17pub mod webhook;
Modified lib/models/schema_extras.sql View lib/models/schema_extras.sql
@@ -177,53 +177,11 @@
177177CREATE INDEX IF NOT EXISTS idx_chat_webhook_delivery_due
178178 ON chat_webhook_delivery (status, next_retry_at);
179179
180ALTER TABLE chat_message ADD COLUMN IF NOT EXISTS search_vector tsvector;
181CREATE INDEX IF NOT EXISTS idx_chat_message_fts
182 ON chat_message USING gin (search_vector);
183CREATE OR REPLACE FUNCTION chat_message_search_vector_update() RETURNS trigger AS $$
184BEGIN
185 NEW.search_vector := to_tsvector('simple', coalesce(NEW.content, ''));
186 RETURN NEW;
187END;
188$$ LANGUAGE plpgsql;
189DROP TRIGGER IF EXISTS chat_message_search_vector_trigger ON chat_message;
190CREATE TRIGGER chat_message_search_vector_trigger
191 BEFORE INSERT OR UPDATE OF content ON chat_message
192 FOR EACH ROW EXECUTE FUNCTION chat_message_search_vector_update();
193UPDATE chat_message SET search_vector = to_tsvector('simple', coalesce(content, ''))
194 WHERE search_vector IS NULL;
195180-- phase-2 data backfills (idempotent)
196181UPDATE repo_watch SET mode = 0 WHERE mode = 1;
197182UPDATE issue_watch SET mode = 0 WHERE mode = 1;
198183UPDATE room_members rm SET role = 3
199184 FROM rooms r WHERE rm.room_id = r.id AND rm.user_id = r.owner_id AND rm.left_at IS NULL;
200CREATE INDEX IF NOT EXISTS idx_workflow_run_repo
201 ON workflow_run (repo_id, created_at DESC);
202CREATE INDEX IF NOT EXISTS idx_workflow_run_concurrency
203 ON workflow_run (repo_id, concurrency_group) WHERE concurrency_group IS NOT NULL;
204CREATE INDEX IF NOT EXISTS idx_workflow_run_commit
205 ON workflow_run (repo_id, commit_sha);
206CREATE INDEX IF NOT EXISTS idx_workflow_job_claim
207 ON workflow_job (state, created_at);
208CREATE INDEX IF NOT EXISTS idx_workflow_job_lease
209 ON workflow_job (lease_expires_at) WHERE lease_expires_at IS NOT NULL;
210CREATE INDEX IF NOT EXISTS idx_workflow_step_job
211 ON workflow_step (job_id, step_index);
212CREATE INDEX IF NOT EXISTS idx_ci_log_chunk_job
213 ON ci_log_chunk (job_id, seq);
214CREATE INDEX IF NOT EXISTS idx_ci_secret_repo
215 ON ci_secret (repo_id, name) WHERE repo_id IS NOT NULL;
216CREATE INDEX IF NOT EXISTS idx_ci_secret_space
217 ON ci_secret (space_id, name) WHERE space_id IS NOT NULL;
218CREATE INDEX IF NOT EXISTS idx_ci_secret_environment
219 ON ci_secret (environment_id, name) WHERE environment_id IS NOT NULL;
220CREATE INDEX IF NOT EXISTS idx_ci_environment_space
221 ON ci_environment (space_id, name);
222CREATE INDEX IF NOT EXISTS idx_ci_deployment_environment
223 ON ci_deployment (environment_id, created_at DESC);
224CREATE INDEX IF NOT EXISTS idx_repo_webhook_delivery_due
225 ON repo_webhook_delivery (status, next_retry_at);
226
227185-- user_access_key is owned by KenOAuth now; GitData only normalises legacy rows
228186-- left in a database that still has the table.
229187DO $$ BEGIN
@@ -291,39 +249,5 @@
291249 ALTER TABLE pull_request
292250 ADD CONSTRAINT pull_request_merged_consistent
293251 CHECK ((has_merged = false) OR merged_at IS NOT NULL);
294 END IF;
295END $$;
296
297CREATE INDEX IF NOT EXISTS idx_ci_runner_state
298 ON ci_runner (state, last_seen_at DESC);
299CREATE UNIQUE INDEX IF NOT EXISTS idx_ci_run_event_key ON workflow_run(event_key) WHERE event_key IS NOT NULL;
300CREATE UNIQUE INDEX IF NOT EXISTS idx_ci_job_run_key ON workflow_job(run_id,job_key);
301CREATE UNIQUE INDEX IF NOT EXISTS idx_ci_step_key_stage ON workflow_step(job_id,step_key,stage) WHERE step_key IS NOT NULL;
302CREATE UNIQUE INDEX IF NOT EXISTS idx_ci_env_repo_name ON ci_environment(repo_id,name) WHERE repo_id IS NOT NULL;
303CREATE UNIQUE INDEX IF NOT EXISTS idx_ci_schedule_key ON ci_actions_schedule(repo_id,workflow_file,cron);
304CREATE UNIQUE INDEX IF NOT EXISTS idx_ci_variable_name ON ci_variable(repo_id,name);
305CREATE UNIQUE INDEX IF NOT EXISTS idx_ci_actions_cache_key ON ci_actions_cache(repo_id,git_ref,key,version);
306
307-- ---------------------------------------------------------------------------
308-- search index queue: coalesce duplicate queued jobs per (kind, target) and
309-- arrive-once state per repository for incremental code indexing.
310-- ---------------------------------------------------------------------------
311DELETE FROM search_index_job s
312USING search_index_job d
313WHERE s.state = 'queued'
314 AND d.state = 'queued'
315 AND s.kind = d.kind
316 AND s.target_id IS NOT DISTINCT FROM d.target_id
317 AND (s.created_at > d.created_at OR (s.created_at = d.created_at AND s.id > d.id));
318
319CREATE UNIQUE INDEX IF NOT EXISTS uniq_search_index_job_queued
320 ON search_index_job (kind, COALESCE(target_id, '00000000-0000-0000-0000-000000000000'::uuid))
321 WHERE state = 'queued';
322
323DO $$ BEGIN
324 IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'search_repo_state_repo_id_fkey') THEN
325 ALTER TABLE search_repo_state
326 ADD CONSTRAINT search_repo_state_repo_id_fkey
327 FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE;
328252 END IF;
329253END $$;
Modified lib/models/schema_pre_sync.sql View lib/models/schema_pre_sync.sql
@@ -17,14 +17,6 @@
1717DROP INDEX IF EXISTS idx_chat_gdm_key;
1818DROP INDEX IF EXISTS idx_chat_draft_unique;
1919DROP INDEX IF EXISTS idx_space_join_request_pending;
20DROP INDEX IF EXISTS idx_ci_run_event_key;
21DROP INDEX IF EXISTS idx_ci_job_run_key;
22DROP INDEX IF EXISTS idx_ci_step_key_stage;
23DROP INDEX IF EXISTS idx_ci_env_repo_name;
24DROP INDEX IF EXISTS idx_ci_schedule_key;
25DROP INDEX IF EXISTS idx_ci_variable_name;
26DROP INDEX IF EXISTS idx_ci_actions_cache_key;
27
2820-- scheduled messages feature removed; drop the legacy table before sync
2921DROP TABLE IF EXISTS user_scheduled_message;
3022
@@ -32,6 +24,48 @@
3224DROP TABLE IF EXISTS admin_audit_log;
3325DROP TABLE IF EXISTS admin_user;
3426
27-- CI, webhook delivery, import and search features removed
28DROP TABLE IF EXISTS ci_job_token CASCADE;
29DROP TABLE IF EXISTS ci_runner_token CASCADE;
30DROP TABLE IF EXISTS ci_runner CASCADE;
31DROP TABLE IF EXISTS ci_log_chunk CASCADE;
32DROP TABLE IF EXISTS workflow_step CASCADE;
33DROP TABLE IF EXISTS workflow_job CASCADE;
34DROP TABLE IF EXISTS workflow_artifact CASCADE;
35DROP TABLE IF EXISTS workflow_run CASCADE;
36DROP TABLE IF EXISTS ci_workflow_counter CASCADE;
37DROP TABLE IF EXISTS ci_actions_identity CASCADE;
38DROP TABLE IF EXISTS ci_actions_schedule CASCADE;
39DROP TABLE IF EXISTS ci_actions_cache CASCADE;
40DROP TABLE IF EXISTS ci_deployment CASCADE;
41DROP TABLE IF EXISTS ci_environment_review CASCADE;
42DROP TABLE IF EXISTS ci_secret CASCADE;
43DROP TABLE IF EXISTS ci_variable CASCADE;
44DROP TABLE IF EXISTS ci_environment CASCADE;
45DROP TABLE IF EXISTS repo_webhook_delivery CASCADE;
46DROP TABLE IF EXISTS repo_webhook CASCADE;
47DROP TABLE IF EXISTS webhook_delivery CASCADE;
48DROP TABLE IF EXISTS webhook CASCADE;
49DROP TABLE IF EXISTS import_job CASCADE;
50DROP TABLE IF EXISTS search_index_job CASCADE;
51DROP TABLE IF EXISTS search_repo_state CASCADE;
52
53-- chat message full-text search removed
54DROP FUNCTION IF EXISTS chat_message_search_vector_update() CASCADE;
55DO $$ BEGIN
56 IF to_regclass('chat_message') IS NOT NULL THEN
57 DROP TRIGGER IF EXISTS chat_message_search_vector_trigger ON chat_message;
58 ALTER TABLE chat_message DROP COLUMN IF EXISTS search_vector;
59 END IF;
60END $$;
61
62-- pipeline tab removed from repository settings
63DO $$ BEGIN
64 IF to_regclass('repos') IS NOT NULL THEN
65 ALTER TABLE repos DROP COLUMN IF EXISTS pipelines_public;
66 END IF;
67END $$;
68
3569-- rooms.parent_id used to be declared as a self-reference, so sync created
3670-- `fk-rooms-parent_id -> rooms(id)`. The column actually points at a directory
3771-- node (dir.id) — update_room validates the target is a same-space DirKind::Dir
@@ -59,21 +93,6 @@
5993 END IF;
6094END $$;
6195
62-- pipeline_run was replaced by workflow_run/workflow_job/workflow_step
63DROP INDEX IF EXISTS idx_workflow_run_repo;
64DROP INDEX IF EXISTS idx_workflow_run_concurrency;
65DROP INDEX IF EXISTS idx_workflow_run_commit;
66DROP INDEX IF EXISTS idx_workflow_job_claim;
67DROP INDEX IF EXISTS idx_workflow_job_lease;
68DROP INDEX IF EXISTS idx_workflow_step_job;
69DROP INDEX IF EXISTS idx_ci_log_chunk_job;
70DROP INDEX IF EXISTS idx_ci_secret_repo;
71DROP INDEX IF EXISTS idx_ci_secret_space;
72DROP INDEX IF EXISTS idx_ci_secret_environment;
73DROP INDEX IF EXISTS idx_ci_environment_space;
74DROP INDEX IF EXISTS idx_ci_deployment_environment;
75DROP INDEX IF EXISTS idx_repo_webhook_delivery_due;
76DROP INDEX IF EXISTS idx_ci_runner_state;
7796DROP TABLE IF EXISTS pipeline_run;
7897
7998-- idempotent column adds for phase-2 model changes (sync layer cannot
Modified lib/pulls/src/handlers/reviewers/delete_requested_reviewer.rs View lib/pulls/src/handlers/reviewers/delete_requested_reviewer.rs
@@ -1,7 +1,6 @@
11use axum::extract::{Path, State};
22use export::rest::AppResponseData;
33use jwt::Session;
4use uuid::Uuid;
54
65use crate::errors::{ApiError, PullError};
76use crate::services::reviewers::PullReviewerDto;
Modified lib/pulls/src/services/changes.rs View lib/pulls/src/services/changes.rs
@@ -5,7 +5,6 @@
55use sea_orm::{ActiveModelTrait, ActiveValue::Set, IntoActiveModel};
66use serde::{Deserialize, Serialize};
77use time::OffsetDateTime;
8use uuid::Uuid;
98
109use crate::errors::PullError;
1110use crate::services::events::EVENT_HEAD_UPDATED;
@@ -154,8 +153,6 @@
154153 None,
155154 )
156155 .await?;
157 self.publish_ci_event_for(&updated, actor, EVENT_HEAD_UPDATED)
158 .await;
159156 }
160157 Ok((
161158 updated,
Modified lib/pulls/src/services/checks.rs View lib/pulls/src/services/checks.rs
@@ -1,12 +1,8 @@
1use ci::triggers::RunDto as PipelineRunDto;
21use git::context::facade::GitContext;
32use git::service::commits::CommitStatusDto;
4use models::ci::workflow_run;
53use models::pull_request::pull_requests;
6use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
74use serde::Serialize;
85use utoipa::ToSchema;
9use uuid::Uuid;
106
117use crate::errors::PullError;
128use crate::services::approvals::MergeGateDto;
@@ -16,7 +12,6 @@
1612pub struct PullChecksDto {
1713 pub head_sha: String,
1814 pub statuses: Vec<CommitStatusDto>,
19 pub runs: Vec<PipelineRunDto>,
2015}
2116
2217#[derive(Debug, Clone, Serialize, ToSchema)]
@@ -37,18 +32,11 @@
3732 ) -> Result<PullChecksDto, PullError> {
3833 let statuses = self
3934 .git
40 .list_commit_statuses(space_name.clone(), repo_name.clone(), pull.head_sha.clone())
35 .list_commit_statuses(space_name, repo_name, pull.head_sha.clone())
4136 .await?;
42 let rows = workflow_run::Entity::find()
43 .filter(workflow_run::Column::RepoId.eq(pull.repo_id))
44 .filter(workflow_run::Column::CommitSha.eq(pull.head_sha.clone()))
45 .order_by_desc(workflow_run::Column::CreatedAt)
46 .all(self.db_read())
47 .await?;
4837 Ok(PullChecksDto {
4938 head_sha: pull.head_sha.clone(),
5039 statuses,
51 runs: rows.into_iter().map(PipelineRunDto::from).collect(),
5240 })
5341 }
5442
Modified lib/pulls/src/services/comments.rs View lib/pulls/src/services/comments.rs
@@ -168,7 +168,6 @@
168168 .await?;
169169 self.record_event(pull, Some(caller), EVENT_COMMENTED, commit_sha, None)
170170 .await?;
171 self.queue_search_pull(pull.id, pull.repo_id).await?;
172171 let _ = ctx;
173172 Ok(row)
174173 }
@@ -257,7 +256,6 @@
257256 model.body = Set(body);
258257 model.updated_at = Set(Some(OffsetDateTime::now_utc()));
259258 let updated = model.update(self.db_write()).await?;
260 self.queue_search_pull(pull.id, pull.repo_id).await?;
261259 let _ = pull;
262260 Ok(updated)
263261 }
@@ -281,7 +279,6 @@
281279 )
282280 .exec(self.db_write())
283281 .await?;
284 self.queue_search_pull(pull.id, pull.repo_id).await?;
285282 Ok(())
286283 }
287284
@@ -306,7 +303,6 @@
306303 });
307304 model.resolved_by = Set(if resolved { Some(caller) } else { None });
308305 let updated = model.update(self.db_write()).await?;
309 self.queue_search_pull(pull.id, pull.repo_id).await?;
310306 let _ = pull;
311307 Ok(updated)
312308 }
Modified lib/pulls/src/services/events.rs View lib/pulls/src/services/events.rs
@@ -90,84 +90,7 @@
9090 content,
9191 )
9292 .await?;
93 self.publish_ci_event(pull, actor, event_type).await;
9493 Ok(())
95 }
96
97 /// Publishes the CI mirror for callers that already wrote their own row
98 /// (for example inside a transaction).
99 pub(crate) async fn publish_ci_event_for(
100 &self,
101 pull: &pull_requests::Model,
102 actor: Option<i64>,
103 event_type: &str,
104 ) {
105 self.publish_ci_event(pull, actor, event_type).await;
106 }
107
108 /// Mirrors CI-relevant pull request activity onto the `gitdata:events`
109 /// queue so the scheduler can evaluate `pull_request` workflow triggers.
110 async fn publish_ci_event(
111 &self,
112 pull: &pull_requests::Model,
113 actor: Option<i64>,
114 event_type: &str,
115 ) {
116 let action = match event_type {
117 EVENT_CREATED => ci_protocol::events::PullRequestAction::Opened,
118 EVENT_HEAD_UPDATED => ci_protocol::events::PullRequestAction::Synchronize,
119 EVENT_REOPENED => ci_protocol::events::PullRequestAction::Reopened,
120 EVENT_MERGED => ci_protocol::events::PullRequestAction::Merged,
121 EVENT_CLOSED => ci_protocol::events::PullRequestAction::Closed,
122 _ => return,
123 };
124 if !self.config().ci.enabled {
125 return;
126 }
127 let Some(space_id) = self.pull_space_id(pull.repo_id).await else {
128 return;
129 };
130 let event =
131 ci_protocol::events::CiEvent::PullRequest(ci_protocol::events::PullRequestEvent {
132 id: Uuid::now_v7(),
133 repo_id: pull.repo_id,
134 space_id,
135 actor_id: actor.or(Some(pull.author_id)),
136 number: pull.autoincrement_id as i64,
137 action,
138 head_sha: pull.head_sha.clone(),
139 head_ref: pull.head_branch.clone(),
140 base_sha: pull.base_sha.clone(),
141 base_ref: pull.base_branch.clone(),
142 head_repo_id: pull.head_repo_id,
143 trace: export::telemetry::traceparent(),
144 request_id: export::telemetry::request_id(),
145 });
146 let payload = match serde_json::to_vec(&event) {
147 Ok(payload) => payload,
148 Err(error) => {
149 tracing::warn!(%error, "ci pull request event serialize failed");
150 return;
151 }
152 };
153 let queue = git::sync::queue::SyncQueue::new(self.state.redis.clone());
154 if let Err(error) = queue
155 .publish_raw(&self.config().ci.event_queue_topic, None, &payload)
156 .await
157 {
158 tracing::warn!(%error, "ci pull request event publish failed");
159 }
160 }
161
162 async fn pull_space_id(&self, repo_id: Uuid) -> Option<Uuid> {
163 let repo = models::repo::repos::Entity::find_by_id(repo_id)
164 .one(self.db_read())
165 .await
166 .ok()??;
167 git::share::access::space_for_dir(self.db_read(), repo.dir_id)
168 .await
169 .ok()?
170 .map(|space| space.id)
17194 }
17295
17396 pub async fn list_events(
Modified lib/pulls/src/services/labels.rs View lib/pulls/src/services/labels.rs
@@ -112,7 +112,6 @@
112112 )
113113 .await?;
114114 }
115 self.queue_search_pull(pull.id, pull.repo_id).await?;
116115 self.list_labels(pull.id).await
117116 }
118117
@@ -140,7 +139,6 @@
140139 )
141140 .await?;
142141 }
143 self.queue_search_pull(pull.id, pull.repo_id).await?;
144142 self.list_labels(pull.id).await
145143 }
146144}
Modified lib/pulls/src/services/merge.rs View lib/pulls/src/services/merge.rs
@@ -5,7 +5,6 @@
55use sea_orm::{ActiveModelTrait, ActiveValue::Set, IntoActiveModel};
66use serde::{Deserialize, Serialize};
77use utoipa::ToSchema;
8use uuid::Uuid;
98
109use crate::errors::PullError;
1110use crate::services::changes::PullSummaryDto;
Modified lib/pulls/src/services/pulls.rs View lib/pulls/src/services/pulls.rs
@@ -412,9 +412,6 @@
412412 .await?;
413413 txn.commit().await?;
414414 self.recount_open(ctx.model.id).await?;
415 self.queue_search_pull(pull.id, pull.repo_id).await?;
416 self.publish_ci_event_for(&pull, Some(caller), EVENT_CREATED)
417 .await;
418415 Ok(pull)
419416 }
420417
@@ -502,7 +499,6 @@
502499 let updated = model.update(self.db_write()).await?;
503500 self.record_event(&updated, Some(caller), EVENT_UPDATED, None, None)
504501 .await?;
505 self.queue_search_pull(updated.id, updated.repo_id).await?;
506502 let _ = (space_name, repo_name);
507503 Ok(updated)
508504 }
@@ -541,7 +537,6 @@
541537 )
542538 .await?;
543539 self.recount_open(updated.repo_id).await?;
544 self.queue_search_pull(updated.id, updated.repo_id).await?;
545540 Ok(updated)
546541 }
547542
@@ -587,7 +582,6 @@
587582 .await?;
588583 txn.commit().await?;
589584 self.recount_open(pull.repo_id).await?;
590 self.queue_search_pull(pull.id, pull.repo_id).await?;
591585 Ok(())
592586 }
593587
@@ -678,7 +672,6 @@
678672 self.record_event(&updated, Some(caller), EVENT_MERGED, Some(merged_sha), None)
679673 .await?;
680674 self.recount_open(updated.repo_id).await?;
681 self.queue_search_pull(updated.id, updated.repo_id).await?;
682675 Ok(updated)
683676 }
684677}
@@ -709,7 +702,6 @@
709702 None,
710703 )
711704 .await?;
712 self.queue_search_pull(updated.id, updated.repo_id).await?;
713705 Ok(updated)
714706 }
715707}
Modified lib/pulls/src/services/reviews.rs View lib/pulls/src/services/reviews.rs
@@ -151,7 +151,6 @@
151151 self.sync_reviewer(pull.id, caller, ReviewerOutcome::from_state(state))
152152 .await?;
153153 }
154 self.queue_search_pull(pull.id, pull.repo_id).await?;
155154 let _ = ctx;
156155 Ok(review)
157156 }
@@ -207,7 +206,6 @@
207206 .await?;
208207 self.sync_reviewer(pull.id, caller, ReviewerOutcome::from_state(target))
209208 .await?;
210 self.queue_search_pull(pull.id, pull.repo_id).await?;
211209 Ok(updated)
212210 }
213211
@@ -240,7 +238,6 @@
240238 .await?;
241239 self.sync_reviewer(pull.id, review.user_id, ReviewerOutcome::Dismissed)
242240 .await?;
243 self.queue_search_pull(pull.id, pull.repo_id).await?;
244241 Ok(updated)
245242 }
246243
Modified lib/pulls/src/services/service.rs View lib/pulls/src/services/service.rs
@@ -36,13 +36,7 @@
3636 self.state.database.write()
3737 }
3838
39 pub async fn queue_search_pull(&self, pull_id: Uuid, repo_id: Uuid) -> Result<(), PullError> {
40 search::outbox::enqueue_pull(self.db_write(), pull_id, repo_id).await?;
41 Ok(())
42 }
43
4439 pub fn uid(session: &Session) -> Result<i64, PullError> {
45 use jwt::SessionUser;
4640 session.uid().ok_or(PullError::Unauthorized)
4741 }
4842
Modified lib/pulls/src/share/authz.rs View lib/pulls/src/share/authz.rs
@@ -1,5 +1,4 @@
11use models::pull_request::pull_requests;
2use uuid::Uuid;
32
43use crate::errors::PullError;
54
Modified lib/pulls/src/share/lookup.rs View lib/pulls/src/share/lookup.rs
@@ -2,7 +2,7 @@
22use git::share::access::authorize_repo_access;
33use models::pull_request::pull_requests;
44use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
5use jwt::{Session, SessionUser};
5use jwt::Session;
66use uuid::Uuid;
77
88use crate::errors::PullError;
Modified lib/pulls/Cargo.toml View lib/pulls/Cargo.toml
@@ -20,9 +20,6 @@
2020time = { workspace = true }
2121tracing = { workspace = true }
2222globset = { workspace = true }
23search = { workspace = true }
24ci-protocol = { workspace = true }
25ci = { workspace = true }
2623
2724
2825
Modified lib/push/src/worker.rs View lib/push/src/worker.rs
@@ -15,7 +15,6 @@
1515use models::users::user_push_subscriptions::{self, PushSubscriptionPlatform};
1616use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
1717use tracing::Instrument as _;
18use uuid::Uuid;
1918
2019use crate::client::{DeliveryOutcome, NotificationPayload, SubscriptionKeys, VapidKeys, deliver};
2120
Deleted lib/search/src/indexer.rs

Diff too large to display.

Deleted lib/search/src/lib.rs

Diff too large to display.

Deleted lib/search/src/outbox.rs

Diff too large to display.

Deleted lib/search/src/parser.rs

Diff too large to display.

Deleted lib/search/src/service.rs

Diff too large to display.

Deleted lib/search/src/worker.rs

Diff too large to display.

Deleted lib/search/Cargo.toml

Diff too large to display.

Modified lib/space/src/handlers/explore_spaces.rs View lib/space/src/handlers/explore_spaces.rs
@@ -1,6 +1,6 @@
11use axum::extract::{Path, Query, State};
22use export::rest::{AppResponseData, NoData};
3use jwt::{Session, SessionUser};
3use jwt::Session;
44
55use crate::errors::ApiError;
66use crate::services::join_requests::{
Modified lib/space/src/handlers/get_group.rs View lib/space/src/handlers/get_group.rs
@@ -1,7 +1,7 @@
11use axum::extract::{Path, State};
22use export::rest::{AppResponseData, NoData};
33use serde::Deserialize;
4use jwt::{Session, SessionUser};
4use jwt::Session;
55use utoipa::{IntoParams, ToSchema};
66use uuid::Uuid;
77
Modified lib/space/src/handlers/get_member.rs View lib/space/src/handlers/get_member.rs
@@ -3,7 +3,6 @@
33use serde::Deserialize;
44use jwt::Session;
55use utoipa::{IntoParams, ToSchema};
6use uuid::Uuid;
76
87use crate::errors::ApiError;
98use crate::services::members::MemberDto;
Modified lib/space/src/handlers/get_space.rs View lib/space/src/handlers/get_space.rs
@@ -1,6 +1,6 @@
11use axum::extract::{Path, State};
22use export::rest::{AppResponseData, NoData};
3use jwt::{Session, SessionUser};
3use jwt::Session;
44
55use crate::errors::ApiError;
66use crate::services::service::SpaceService;
Modified lib/space/src/handlers/get_space_image.rs View lib/space/src/handlers/get_space_image.rs
@@ -1,6 +1,6 @@
11use axum::extract::{Path, State};
22use export::rest::{AppResponseData, NoData};
3use jwt::{Session, SessionUser};
3use jwt::Session;
44
55use crate::errors::ApiError;
66use crate::services::images::{SpaceImageDto, SpaceImageKindPath};
Modified lib/space/src/handlers/get_space_image_file.rs View lib/space/src/handlers/get_space_image_file.rs
@@ -3,7 +3,7 @@
33use axum::response::Response;
44use imaging::layout::ImageVariant;
55use serde::Deserialize;
6use jwt::{Session, SessionUser};
6use jwt::Session;
77use utoipa::{IntoParams, ToSchema};
88
99use crate::errors::ApiError;
Modified lib/space/src/services/rooms.rs View lib/space/src/services/rooms.rs
@@ -257,17 +257,10 @@
257257 }
258258
259259 let now = OffsetDateTime::now_utc();
260 let room_id = room.id;
261260 let mut am: rooms::ActiveModel = room.into();
262261 am.is_active = Set(false);
263262 am.updated_at = Set(now);
264263 am.update(self.db().write()).await?;
265 // 频道停用后它的消息不该再出现在全局搜索里:房间级索引任务会先
266 // 按 room_id 清掉文档,再因为房间已停用而不回填。
267 if let Err(error) = search::outbox::enqueue_room_messages(self.db().write(), room_id).await
268 {
269 tracing::warn!(%room_id, error = %error, "search room purge enqueue failed");
270 }
271264
272265 Ok(())
273266 }
Modified lib/space/src/services/service.rs View lib/space/src/services/service.rs
@@ -3,7 +3,6 @@
33use config::AppConfig;
44use export::{AppDatabase, AppState, AppStorage};
55
6use uuid::Uuid;
76
87use crate::errors::SpaceError;
98
Modified lib/space/src/services/spaces.rs View lib/space/src/services/spaces.rs
@@ -1,12 +1,10 @@
11use std::collections::HashMap;
22
3use models::room::rooms;
4use models::space::space_members::{self, MemberStatus, MessageNotifications};
53use models::space::spaces::{self, AllowJoinLevel};
64use models::users::user;
75use sea_orm::{
8 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
9 QuerySelect, Set, TransactionTrait,
6 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
7 Set,
108};
119use serde::{Deserialize, Serialize};
1210use time::OffsetDateTime;
@@ -486,27 +484,10 @@
486484 .await?;
487485
488486 let now = OffsetDateTime::now_utc();
489 let space_id = space.id;
490487 let mut am: spaces::ActiveModel = space.into();
491488 am.is_active = Set(false);
492489 am.updated_at = Set(now);
493490 am.update(self.db().write()).await?;
494 // 空间停用后它的频道消息必须退出全局搜索:没有单条消息会再触发
495 // 重建,所以这里逐频道排一次房间级清理。
496 let room_ids: Vec<Uuid> = rooms::Entity::find()
497 .filter(rooms::Column::SpaceId.eq(space_id))
498 .all(self.db().read())
499 .await?
500 .into_iter()
501 .map(|room| room.id)
502 .collect();
503 for room_id in room_ids {
504 if let Err(error) =
505 search::outbox::enqueue_room_messages(self.db().write(), room_id).await
506 {
507 tracing::warn!(%room_id, error = %error, "search room purge enqueue failed");
508 }
509 }
510491 Ok(())
511492 }
512493
@@ -522,7 +503,7 @@
522503 .unwrap_or(0)
523504 .clamp(0, export::pagination::MAX_OFFSET);
524505
525 let mut select = spaces::Entity::find()
506 let select = spaces::Entity::find()
526507 .filter(spaces::Column::IsActive.eq(true))
527508 .filter(spaces::Column::OwnerId.eq(caller))
528509 .order_by_desc(spaces::Column::CreatedAt);
Modified lib/space/src/share/authz.rs View lib/space/src/share/authz.rs
@@ -1,9 +1,8 @@
11use std::collections::HashSet;
22
33use export::AppState;
4use models::space::space_members::MemberStatus;
5use models::space::{space_bans, space_members, spaces};
6use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter};
4use models::space::{space_bans, spaces};
5use sea_orm::{ConnectionTrait, EntityTrait};
76use time::OffsetDateTime;
87use uuid::Uuid;
98
Modified lib/space/Cargo.toml View lib/space/Cargo.toml
@@ -4,14 +4,12 @@
44edition.workspace = true
55
66[dependencies]
7rand = { workspace = true }
87async-trait = { workspace = true }
98config = { workspace = true }
109export = { workspace = true }
1110i18n = { workspace = true }
1211imaging = { workspace = true }
1312models = { workspace = true }
14search = { workspace = true }
1513jwt = { workspace = true }
1614kenauth = { workspace = true }
1715prost-types = { workspace = true }
Modified lib/user/src/handlers/get_profile_detail.rs View lib/user/src/handlers/get_profile_detail.rs
@@ -1,7 +1,6 @@
11use axum::extract::{Path, State};
22use export::rest::{AppResponseData, NoData};
3use jwt::{Session, SessionUser};
4use uuid::Uuid;
3use jwt::Session;
54
65use crate::errors::ApiError;
76use crate::services::profile::ProfileDetailDto;
Modified lib/user/src/handlers/get_user_avatar.rs View lib/user/src/handlers/get_user_avatar.rs
@@ -6,7 +6,6 @@
66use jwt::Session;
77use jwt::share::session::require_uid;
88use utoipa::{IntoParams, ToSchema};
9use uuid::Uuid;
109
1110use crate::errors::ApiError;
1211use crate::services::service::UserService;
Modified lib/user/src/services/avatar.rs View lib/user/src/services/avatar.rs
@@ -5,7 +5,6 @@
55use serde::{Deserialize, Serialize};
66use time::OffsetDateTime;
77use utoipa::ToSchema;
8use uuid::Uuid;
98
109use crate::errors::UserError;
1110use crate::services::service::UserService;
Modified lib/user/src/services/service.rs View lib/user/src/services/service.rs
@@ -2,7 +2,6 @@
22
33use config::AppConfig;
44use export::{AppDatabase, AppState, AppStorage};
5use uuid::Uuid;
65
76#[derive(Clone)]
87pub struct UserService {
@@ -18,11 +17,6 @@
1817 &self.state.config
1918 }
2019
21 /// Language for one recipient's account mail.
22 pub(crate) async fn recipient_locale(&self, uid: i64) -> i18n::UiLocale {
23 let fallback = i18n::config_fallback(self.config().email.default_locale());
24 i18n::resolve_user_locale(self.db().read(), uid, fallback).await
25 }
2620
2721 pub fn db(&self) -> &Arc<AppDatabase> {
2822 &self.state.database
Modified lib/user/src/routes.rs View lib/user/src/routes.rs
@@ -1,5 +1,5 @@
11use axum::Router;
2use axum::routing::{delete, get, patch, post, put};
2use axum::routing::{delete, get, put};
33
44use crate::handlers::create_meme::create_meme;
55use crate::handlers::delete_avatar::delete_avatar;
Modified lib/webapi/src/lib.rs View lib/webapi/src/lib.rs
@@ -1,6 +1,5 @@
11pub mod cleanup;
22pub mod openapi;
3pub mod search;
43pub mod spa;
54
65use axum::Router;
@@ -82,12 +81,7 @@
8281 let space = space::services::service::SpaceService::new(state.clone());
8382 let user = user::services::service::UserService::new(state.clone());
8483 let git = git::service::GitService::new(state.clone());
85 let ci = ci::CiService::new(state.clone());
8684 let pulls = pulls::services::service::PullService::new(state.clone());
87 let search =
88 ::search::SearchService::from_parts(state.database.clone(), state.config.search.clone())
89 .expect("validated search client configuration");
90 let import = ::import::ImportService::new(state.clone());
9185 let internal_service_key = state.config.internal.service_key.clone();
9286 let jwt_verifier = if state.config.oauth.enabled {
9387 match jwt::JwtVerifier::new(&state.config.oauth) {
@@ -115,14 +109,9 @@
115109 .merge(user::routes::router(user.clone()).with_state(user))
116110 .merge(git::routes::router(git.clone()).with_state(git.clone()))
117111 .merge(pulls::routes::router().with_state(pulls))
118 .merge(crate::search::router().with_state(search.clone()))
119 .merge(::import::routes::router(import.clone()).with_state(import.clone()))
120 .merge(ci::routes::router().with_state(ci.clone()))
121112 .merge(cleanup_router);
122113 let internal: Router = Router::new()
123114 .merge(git::routes::internal_router(git.clone()).with_state(git))
124 .merge(crate::search::internal_router().with_state(search))
125 .merge(::import::routes::internal_router().with_state(import))
126115 .route_layer(axum::middleware::from_fn_with_state(
127116 internal_service_key,
128117 require_service_key,
@@ -158,5 +147,5 @@
158147 None => router,
159148 };
160149
161 Ok(router.merge(ci::actions_runtime::router().with_state(ci)))
150 Ok(router)
162151}
Modified lib/webapi/src/openapi.rs View lib/webapi/src/openapi.rs
@@ -112,9 +112,6 @@
112112 m!("user", user::openapi::UserApiDoc::openapi());
113113 m!("git", git::openapi::GitApiDoc::openapi());
114114 m!("pulls", pulls::openapi::PullsApiDoc::openapi());
115 m!("search", crate::search::SearchApiDoc::openapi());
116 m!("import", import::openapi::ImportApiDoc::openapi());
117 m!("ci", ci::openapi::PipelinesApiDoc::openapi());
118115 m!("cleanup", crate::cleanup::CleanupApiDoc::openapi());
119116 doc.servers = Some(vec![utoipa::openapi::Server::new("/api/v1")]);
120117 doc.info.description = Some(CONVENTIONS.to_string());
Deleted lib/webapi/src/search.rs

Diff too large to display.

Modified lib/webapi/Cargo.toml View lib/webapi/Cargo.toml
@@ -12,9 +12,6 @@
1212space = { workspace = true }
1313user = { workspace = true }
1414git = { workspace = true }
15ci = { workspace = true }
16import = { workspace = true }
17search = { workspace = true }
1815models = { workspace = true }
1916sea-orm = { workspace = true }
2017export = { workspace = true }
Modified scripts/dev.ps1 View scripts/dev.ps1
@@ -29,26 +29,19 @@
2929 migrate = @{ Exe = 'migrate'; Port = 0; Health = $false }
3030 apihost = @{ Exe = 'apihost'; Port = 4081; Health = $true }
3131 im = @{ Exe = 'im'; Port = 4082; Health = $true }
32 synchost = @{ Exe = 'synchost'; Port = 4083; Health = $true }
33 searchhost = @{ Exe = 'searchhost'; Port = 0; Health = $false }
34 webhook = @{ Exe = 'webhook'; Port = 3005; Health = $true }
3532 web = @{ Bun = 'web'; Port = 5173; Health = $false }
3633}
37$Order = @('migrate', 'apihost', 'im', 'synchost', 'searchhost', 'webhook', 'web')
38$Backends = @('migrate', 'apihost', 'im', 'synchost', 'searchhost', 'webhook')
34$Order = @('migrate', 'apihost', 'im', 'web')
35$Backends = @('migrate', 'apihost', 'im')
3936$Aliases = @{
4037 api = 'apihost'
4138 chat = 'im'
42 sync = 'synchost'
43 hook = 'webhook'
4439 ui = 'web'
4540}
4641
4742$Urls = [ordered]@{
4843 apihost = 'http://127.0.0.1:4081 (Swagger: /api/docs)'
4944 im = 'ws://127.0.0.1:4082'
50 synchost = 'http://127.0.0.1:4083/healthz'
51 webhook = 'http://127.0.0.1:3005'
5245 web = 'http://127.0.0.1:5173'
5346}
5447
@@ -56,7 +49,6 @@
5649 @{ Name = 'postgresql'; Port = 5432 },
5750 @{ Name = 'redis8'; Port = 6379 },
5851 @{ Name = 'rustfs'; Port = 9000 },
59 @{ Name = 'meilisearch'; Port = 7700 },
6052 @{ Name = 'livekit'; Port = 7880 }
6153)
6254
@@ -328,7 +320,7 @@
328320 }
329321 if (-not $ok) {
330322 Write-Info ''
331 Write-Info 'just infra 启动 WSL 侧 PostgreSQL / Redis / RustFS / Meilisearch'
323 Write-Info 'just infra 启动 WSL 侧 PostgreSQL / Redis / RustFS'
332324 Write-Info 'just build 构建全部后端二进制(前端由 build.rs 自动构建并内嵌)'
333325 }
334326}
Modified scripts/dev.sh View scripts/dev.sh
@@ -1,7 +1,7 @@
11#!/usr/bin/env bash
22# GitData 本地开发编排脚本(macOS / Linux)。
33# 由 justfile 调用:just <cmd> [svc...] -> scripts/dev.sh <cmd> [svc...]
4# 依赖服务(PostgreSQL / Redis / S3 / Meilisearch)由 `just infra` 通过 docker compose 托管。
4# 依赖服务(PostgreSQL / Redis / S3)由 `just infra` 通过 docker compose 托管。
55set -u
66
77ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@@ -26,9 +26,9 @@
2626DEV_HOST_KEY="$DEV_DIR/ssh_host_ed25519"
2727COMPOSE_FILE="$ROOT/docker-compose.dev.yml"
2828
29ORDER="apihost im synchost webhook web"
30BACKENDS="apihost im synchost webhook"
31INFRA="postgresql:5432 redis:6379 s3:9000 meilisearch:7700"
29ORDER="apihost im web"
30BACKENDS="apihost im"
31INFRA="postgresql:5432 redis:6379 s3:9000"
3232
3333if [ -t 1 ]; then
3434 C_WARN="$(printf '\033[33m')"; C_ERR="$(printf '\033[31m')"; C_OFF="$(printf '\033[0m')"
@@ -53,8 +53,6 @@
5353 case "$1" in
5454 apihost) echo 4081 ;;
5555 im) echo 4082 ;;
56 synchost) echo 4083 ;;
57 webhook) echo 3005 ;;
5856 web) echo 5173 ;;
5957 *) echo 0 ;;
6058 esac
@@ -70,8 +68,6 @@
7068 case "$1" in
7169 api) echo apihost ;;
7270 chat) echo im ;;
73 sync) echo synchost ;;
74 hook) echo webhook ;;
7571 ui) echo web ;;
7672 *) echo "$1" ;;
7773 esac
@@ -81,8 +77,6 @@
8177 case "$1" in
8278 apihost) echo "http://127.0.0.1:4081 (Swagger: /api/docs)" ;;
8379 im) echo "ws://127.0.0.1:4082" ;;
84 synchost) echo "http://127.0.0.1:4083/healthz" ;;
85 webhook) echo "http://127.0.0.1:3005" ;;
8680 web) echo "http://127.0.0.1:5173" ;;
8781 esac
8882}
@@ -254,7 +248,7 @@
254248 fi
255249 printf ' %-12s %s\n' 'os' "$(uname -s)"
256250
257 info '== 依赖服务(PostgreSQL / Redis / S3 / Meilisearch)=='
251 info '== 依赖服务(PostgreSQL / Redis / S3)=='
258252 for pair in $INFRA; do
259253 name="${pair%%:*}"; port="${pair##*:}"
260254 if port_open "$port"; then
@@ -311,7 +305,7 @@
311305 info ' 二选一:'
312306 info ' macOS: brew install --cask docker (或 colima: brew install colima && colima start)'
313307 info ' Linux: 安装 docker engine + compose plugin'
314 info ' 也可自行启动 PostgreSQL/Redis/S3/Meilisearch,或用 APP_* 环境变量指向已有实例'
308 info ' 也可自行启动 PostgreSQL/Redis/S3,或用 APP_* 环境变量指向已有实例'
315309 return 1
316310 fi
317311 if ! docker compose version >/dev/null 2>&1; then
Modified web/src/entities/chat/model/api.ts View web/src/entities/chat/model/api.ts
@@ -31,7 +31,6 @@
3131 ReactionUser,
3232 RoomBrief,
3333 RoomSetting,
34 SearchHit,
3534 SendMessageInput,
3635 SpaceReadStates,
3736 ThreadBrief,
@@ -334,30 +333,6 @@
334333 data: { message_id: nz(messageId) ?? null },
335334 }),
336335 )
337
338/* ------------------------------------------------------------------ search */
339
340export interface SearchQuery {
341 q: string
342 roomId?: string
343 authorId?: number
344 limit?: number
345 offset?: number
346}
347
348export const searchMessages = (spaceName: string, query: SearchQuery) =>
349 unwrapApi<{ items?: SearchHit[]; total?: number } | null>(
350 Apis.chat.search_messages({
351 pathParams: { space_name: spaceName },
352 params: {
353 q: query.q,
354 room_id: nz(query.roomId),
355 author_id: nz(query.authorId),
356 limit: query.limit ?? 30,
357 offset: query.offset ?? 0,
358 },
359 }),
360 ).then(page => ({ items: page?.items ?? [], total: page?.total ?? 0 }))
361336
362337/* ------------------------------------------------------------ space people */
363338
Modified web/src/entities/chat/model/chat.ts View web/src/entities/chat/model/chat.ts
@@ -15,7 +15,6 @@
1515 AppResponseData_ReadStateDto,
1616 AppResponseData_RoomBriefDto,
1717 AppResponseData_RoomSettingDto,
18 AppResponseData_SearchPage,
1918 AppResponseData_SpaceReadStatePage,
2019 AppResponseData_ThreadDto,
2120 AppResponseData_UploadIntentDto,
@@ -50,7 +49,6 @@
5049export type ReadState = NonNullable<AppResponseData_ReadStateDto['data']>
5150export type Draft = NonNullable<AppResponseData_DraftDto['data']>
5251export type SpaceReadStates = NonNullable<AppResponseData_SpaceReadStatePage['data']>
53export type SearchHit = NonNullable<AppResponseData_SearchPage['data']>['items'][number]
5452export type CustomEmoji = NonNullable<AppResponseData_Page_EmojiDto['data']>['items'][number]
5553export type UploadIntent = NonNullable<AppResponseData_UploadIntentDto['data']>
5654export type PinnedPage = NonNullable<AppResponseData_Page_MessageDto['data']>
Deleted web/src/entities/pipeline/model/pipeline.ts

Diff too large to display.

Deleted web/src/entities/pipeline/index.ts

Diff too large to display.

Deleted web/src/entities/repo/model/imports.ts

Diff too large to display.

Modified web/src/entities/repo/index.ts View web/src/entities/repo/index.ts
@@ -92,23 +92,6 @@
9292 type RemoteRow,
9393} from './model/remotes'
9494export {
95 probeImport,
96 createImport,
97 listImports,
98 getImport,
99 cancelImport,
100 retryImport,
101 type CreateImportInput,
102 type ImportJob,
103 type ImportJobPage,
104 type ImportJobState,
105 type ImportProbe,
106 type ImportProvider,
107 type ImportStage,
108 type ImportStats,
109 type ProbeImportInput,
110} from './model/imports'
111export {
11295 createArchive,
11396 createBundle,
11497 queryArchive,
Deleted web/src/entities/search/model/api.ts

Diff too large to display.

Deleted web/src/entities/search/model/search.ts

Diff too large to display.

Deleted web/src/entities/search/index.ts

Diff too large to display.

Modified web/src/features/chat/model/view-state.ts View web/src/features/chat/model/view-state.ts
@@ -6,7 +6,7 @@
66 * feature never imports react-router and never learns what a query string is.
77 * That is also what keeps every panel, thread and jump deep-linkable.
88 */
9export type ChatPanelKind = 'thread' | 'pins' | 'threads' | 'members' | 'search' | null
9export type ChatPanelKind = 'thread' | 'pins' | 'threads' | 'members' | null
1010
1111export interface ChatViewState {
1212 /** Which right-hand column is open; `null` leaves just the transcript. */
@@ -17,10 +17,6 @@
1717 replyToId?: string
1818 editId?: string
1919 jumpId?: string
20 /** Search text (kept in the URL so a result set is shareable). */
21 query?: string
22 /** Search inside this room only, or across the whole space. */
23 scopeRoom?: boolean
2420}
2521
2622/** Defaults are omitted from the URL, so a plain room reads `/app/x/chat/<id>`. */
Modified web/src/features/chat/ui/chat-room.tsx View web/src/features/chat/ui/chat-room.tsx
@@ -54,13 +54,11 @@
5454 leaveRoom as leaveRoomApi,
5555 listVoiceParticipants,
5656 type VoiceParticipant,
57 searchMessages,
5857 updateRoom,
5958 updateRoomSetting,
6059 type ChatMessage,
6160 type RoomBrief,
6261 type RoomKindName,
63 type SearchHit,
6462 type ThreadBrief,
6563 type UserInfo,
6664} from '@/entities/chat'
@@ -74,7 +72,6 @@
7472 MembersPanel,
7573 PanelHeader,
7674 PinsPanel,
77 SearchPanel,
7875 ThreadPanel,
7976 ThreadsPanel,
8077} from './side-panel'
@@ -89,12 +86,6 @@
8986import './chat-polish.css'
9087import './voice/voice.css'
9188
92interface SearchState {
93 query: string
94 error: unknown
95 hits: SearchHit[]
96}
97
9889/** The detail pane stays useful while always leaving a visible chat column. */
9990const PANEL_MIN_WIDTH = 280
10091const CHAT_MIN_WIDTH = 100
@@ -119,8 +110,6 @@
119110 onRoomGone: (reason: 'deleted' | 'left' | 'dm_closed') => void
120111 /** A DM room was opened for a roster member: the page owns the route change. */
121112 onDmOpened: (roomId: string) => void
122 /** A search hit lives in another room of this space: the page routes there. */
123 onOpenRemoteMessage: (roomId: string, messageId: string) => void
124113}
125114
126115export function ChatRoom({
@@ -135,7 +124,6 @@
135124 onUnreadChanged,
136125 onRoomGone,
137126 onDmOpened,
138 onOpenRemoteMessage,
139127}: ChatRoomProps) {
140128 const { t } = useT()
141129 const [version, setVersion] = useState(0)
@@ -285,37 +273,6 @@
285273 stream.jumpTo(id)
286274 }, [state.jumpId, jumpingInThread, stream])
287275
288 /* ------------------------------------------------------------- search */
289
290 // The query lives in the URL (shareable, back-button friendly), so the request
291 // is debounced here instead of fired per keystroke, and a result set is only
292 // ever read back when it belongs to the query that is currently shown.
293 const query = (state.query ?? '').trim()
294 const activeQuery = state.panel === 'search' ? query : ''
295 const [search, setSearch] = useState<SearchState>({ query: '', hits: [], error: null })
296 useEffect(() => {
297 if (!activeQuery) return
298 let active = true
299 const timer = setTimeout(() => {
300 searchMessages(dm ? '@me' : spaceName, {
301 q: activeQuery,
302 // A DM has no space to search; scoping to the room is the honest default.
303 roomId: state.scopeRoom === false ? undefined : roomId,
304 limit: 50,
305 }).then(
306 page => active && setSearch({ query: activeQuery, hits: page.items, error: null }),
307 error => active && setSearch({ query: activeQuery, hits: [], error }),
308 )
309 }, 320)
310 return () => {
311 active = false
312 clearTimeout(timer)
313 }
314 }, [activeQuery, dm, roomId, spaceName, state.scopeRoom])
315 const searchHits = search.query === activeQuery ? search.hits : []
316 const searchLoading = Boolean(activeQuery) && search.query !== activeQuery
317 const searchError = search.query === activeQuery ? search.error : null
318
319276 const jumped = useRef<string | null>(null)
320277
321278 /* -------------------------------------------------------- interactions */
@@ -461,10 +418,10 @@
461418 const closePanel = useCallback(() => {
462419 setFocusedPanelKey(null)
463420 setPanelHasCustomWidth(false)
464 onStateChange({ panel: null, threadId: undefined, query: undefined })
421 onStateChange({ panel: null, threadId: undefined })
465422 }, [onStateChange])
466423 const togglePanel = useCallback(
467 (panel: 'search' | 'pins' | 'threads' | 'members') => {
424 (panel: 'pins' | 'threads' | 'members') => {
468425 if (state.panel === panel) {
469426 closePanel()
470427 return
@@ -849,27 +806,7 @@
849806 onChanged={() => setVersion(value => value + 1)}
850807 onClose={closePanel}
851808 />
852 ) : (
853 <SearchPanel
854 hits={searchHits}
855 loading={searchLoading}
856 error={searchError}
857 query={state.query ?? ''}
858 onQuery={value => onStateChange({ query: value || undefined })}
859 onlyThisRoom={state.scopeRoom !== false}
860 onToggleScope={value => onStateChange({ scopeRoom: value })}
861 onJump={hit => {
862 onStateChange({ panel: null })
863 // Space-wide results include other channels: this transcript can
864 // only address rows that belong to its own room.
865 if (hit.room_id === roomId) stream.jumpTo(hit.message_id)
866 else onOpenRemoteMessage(hit.room_id, hit.message_id)
867 }}
868 onClose={closePanel}
869 nameOf={nameOf}
870 roomLabel={title}
871 />
872 )}
809 ) : null}
873810 </aside>
874811 </>
875812 ) : null}
Modified web/src/features/chat/ui/side-panel.tsx View web/src/features/chat/ui/side-panel.tsx
@@ -1,13 +1,11 @@
11/**
2 * The right-hand column: one thread, the thread list, the pin list, the member
3 * roster, or message search. Only one is mounted at a time and the choice lives
4 * in the URL (see `pages/workspace/model/chat-location`), so every panel is
5 * deep-linkable and survives a reload without a document navigation.
2 * The right-hand column: one thread, the thread list, the pin list, or the
3 * member roster. Only one is mounted at a time and the choice lives in the URL
4 * (see `pages/workspace/model/chat-location`), so every panel is deep-linkable
5 * and survives a reload without a document navigation.
66 *
7 * Each panel degrades on its own terms: a private room whose member list is
8 * forbidden says so instead of showing an empty roster, and search where the API
9 * has none (a DM has no space to search) is explained rather than silently
10 * returning nothing.
7 * A private room whose member list is forbidden says so instead of showing an
8 * empty roster.
119 */
1210import { useEffect, useMemo, useRef, useState } from 'react'
1311import {
@@ -19,7 +17,6 @@
1917 Lock,
2018 MessageSquare,
2119 Pin,
22 Search,
2320 UserPlus,
2421 Users,
2522 X,
@@ -39,7 +36,6 @@
3936 type CustomEmoji,
4037 type Presence,
4138 type RoomMember,
42 type SearchHit,
4339 type ThreadBrief,
4440} from '@/entities/chat'
4541import { sendTypingSignal } from '@/shared/ws'
@@ -673,110 +669,6 @@
673669 )
674670}
675671
676/* ------------------------------------------------------------------ search */
677
678export function SearchPanel({
679 hits,
680 loading,
681 error,
682 query,
683 onQuery,
684 onlyThisRoom,
685 onToggleScope,
686 onJump,
687 onClose,
688 nameOf,
689 roomLabel,
690}: {
691 hits: SearchHit[]
692 loading: boolean
693 error?: unknown
694 query: string
695 onQuery: (value: string) => void
696 onlyThisRoom: boolean
697 onToggleScope: (value: boolean) => void
698 onJump: (hit: SearchHit) => void
699 onClose: () => void
700 nameOf: (userId: number | null | undefined) => string
701 roomLabel: string
702}) {
703 const { t } = useT()
704 return (
705 <>
706 <PanelHeader title={t('chat.search.placeholder')} subtitle={`#${roomLabel}`} onClose={onClose} />
707 <div className="shrink-0 space-y-2 p-2.5">
708 <div className="flex items-center gap-2 rounded-md border-2 border-shell-hairline bg-shell-field px-2">
709 <Search className="h-3.5 w-3.5 shrink-0 text-shell-ink-muted" />
710 <input
711 autoFocus
712 value={query}
713 aria-label={t('chat.search.aria')}
714 onChange={event => onQuery(event.target.value)}
715 placeholder={t('chat.search.inputPlaceholder')}
716 className="h-8 min-w-0 flex-1 bg-transparent text-[13px] outline-none"
717 />
718 {query ? (
719 <button
720 type="button"
721 aria-label={t('common.action.clear')}
722 onClick={() => onQuery('')}
723 className="text-shell-ink-muted hover:text-shell-ink"
724 >
725 <X className="h-3.5 w-3.5" />
726 </button>
727 ) : null}
728 </div>
729 <label className="flex items-center gap-1.5 text-[11.5px] text-shell-ink-muted">
730 <input
731 type="checkbox"
732 checked={onlyThisRoom}
733 onChange={event => onToggleScope(event.target.checked)}
734 />
735 {t('chat.search.onlyRoom')}
736 </label>
737 </div>
738 <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
739 {error ? (
740 <PanelNote>
741 {Number((error as { code?: number }).code ?? 0) === 404
742 ? t('chat.search.unsupported')
743 : describeFailure(error)}
744 </PanelNote>
745 ) : loading ? (
746 <PanelBusy label={t('chat.panel.searching')} />
747 ) : query.trim() ? (
748 hits.length === 0 ? (
749 <PanelEmpty icon={<Search className="h-[18px] w-[18px]" />}>
750 {t('chat.search.emptyFor', { query: query.trim() })}
751 </PanelEmpty>
752 ) : (
753 <ul className="space-y-1.5">
754 {hits.map(hit => (
755 <li key={hit.message_id}>
756 <button
757 type="button"
758 onClick={() => onJump(hit)}
759 className="w-full rounded-md border border-shell-hairline bg-shell-field p-2 text-left transition-colors hover:border-shell-ink-muted"
760 >
761 <p className="flex items-baseline gap-1.5 text-[12px]">
762 <span className="truncate font-semibold">{nameOf(hit.author_id)}</span>
763 <span className="shrink-0 text-[10.5px] text-shell-ink-muted">
764 {clockStamp(hit.created_at)}
765 </span>
766 </p>
767 <p className="line-clamp-2 whitespace-pre-wrap break-words text-[12.5px]">{hit.content}</p>
768 </button>
769 </li>
770 ))}
771 </ul>
772 )
773 ) : (
774 <PanelEmpty icon={<Search className="h-[18px] w-[18px]" />}>{t('chat.search.hint')}</PanelEmpty>
775 )}
776 </div>
777 </>
778 )
779}
780672
781673/* ----------------------------------------------------------------- threads */
782674
Modified web/src/features/chat/index.ts View web/src/features/chat/index.ts
@@ -1,5 +1,5 @@
11// Public API of features/chat — the 聊天室 column of the workspace page: the
2// transcript, the composer, the thread/pin/member/search panels and the room's
2// transcript, the composer, the thread/pin/member panels and the room's
33// own dialogs. Realtime comes from the app-level socket in '@/shared/ws'; message
44// rows and transport live in '@/entities/chat'.
55// Consumers import from '@/features/chat', never from deeper files.
Modified web/src/features/pull-request/ui/checks-tab.tsx View web/src/features/pull-request/ui/checks-tab.tsx
@@ -1,21 +1,12 @@
11import { useState } from 'react'
2import { CircleCheck, CircleDashed, CircleX, Clock, Loader } from 'lucide-react'
32import { ErrorPanel, PanelNote } from '@/shared/ui'
43import type { MessageKey } from '@/shared/i18n'
5import { fmtRelative, useAsync } from '@/shared/lib'
4import { useAsync } from '@/shared/lib'
65import { useT } from '@/shared/i18n'
76import { getPullChecks, type PullRef } from '@/entities/pull-request'
87import { Copyable } from './parts'
98
10type CheckState = { labelKey: MessageKey | null; icon?: typeof Clock; tone: string }
11
12const RUN_STATE: Record<string, CheckState> = {
13 pending: { labelKey: 'pull.checks.pending', icon: CircleDashed, tone: 'text-shell-ink-muted' },
14 running: { labelKey: 'pull.checks.running', icon: Loader, tone: 'text-[#8a6100]' },
15 success: { labelKey: 'pull.checks.success', icon: CircleCheck, tone: 'text-[#1a7f37]' },
16 failed: { labelKey: 'pull.checks.failed', icon: CircleX, tone: 'text-[#b42318]' },
17 cancelled: { labelKey: 'pull.checks.cancelled', icon: Clock, tone: 'text-shell-ink-muted' },
18}
9type CheckState = { labelKey: MessageKey | null; tone: string }
1910
2011const STATUS_STATE: Record<string, CheckState> = {
2112 pending: { labelKey: 'pull.checks.waiting', tone: 'text-shell-ink-muted' },
@@ -24,7 +15,7 @@
2415 error: { labelKey: 'pull.checks.error', tone: 'text-[#b42318]' },
2516}
2617
27/** 检查页签:最新 head 的流水线运行 + 提交状态。 */
18/** 检查页签:最新 head 的提交状态。 */
2819export function ChecksTab({ refObj, headSha }: { refObj: PullRef; headSha: string }) {
2920 const { t } = useT()
3021 const [tick, setTick] = useState(0)
@@ -34,26 +25,13 @@
3425 if (failure) return <ErrorPanel status={failure.code ?? 0} what={t('pull.checks.errorChecks')} note={failure.message} onRetry={() => setTick(tick => tick + 1)} />
3526 const data = checks.data
3627 if (!data) return <PanelNote text={t('pull.checks.empty')} />
37 const runs = data.runs ?? []
3828 const statuses = data.statuses ?? []
39 if (!runs.length && !statuses.length) {
29 if (!statuses.length) {
4030 return <PanelNote text={t('pull.checks.versionEmpty', { sha: (data.head_sha || headSha).slice(0, 8) })} />
4131 }
4232 return (
4333 <div className="space-y-3">
4434 <p className="text-[12.5px] text-shell-ink-muted">{t('pull.checks.version')} <Copyable text={data.head_sha || headSha} /></p>
45 {runs.map(run => {
46 const hit: CheckState = RUN_STATE[run.state] ?? { labelKey: null, icon: Clock, tone: 'text-shell-ink-muted' }
47 const Icon = hit.icon ?? Clock
48 return (
49 <div key={run.id} className="flex flex-wrap items-center gap-2 rounded-xl border border-shell-hairline bg-shell-surface px-4 py-2.5 text-[13px]">
50 <Icon className={`h-4 w-4 shrink-0 ${hit.tone}`} aria-hidden />
51 <span className="font-medium">{t('pull.checks.pipeline', { id: run.id.slice(0, 8) })}</span>
52 <span className={`text-[12px] ${hit.tone}`}>{hit.labelKey ? t(hit.labelKey) : run.state}</span>
53 <span className="text-[12px] text-shell-ink-muted">{run.branch || '—'} · {fmtRelative(run.created_at)}</span>
54 </div>
55 )
56 })}
5735 {statuses.map((status, index) => {
5836 const hit = STATUS_STATE[status.state] ?? { labelKey: null, tone: 'text-shell-ink-muted' }
5937 return (
Deleted web/src/features/repo-pipelines/ui/pipelines-view.tsx

Diff too large to display.

Deleted web/src/features/repo-pipelines/index.ts

Diff too large to display.

Modified web/src/features/repo-settings/model/sections.ts View web/src/features/repo-settings/model/sections.ts
@@ -13,22 +13,18 @@
1313 | 'features'
1414 | 'topics'
1515 | 'remotes'
16 | 'import'
1716 | 'archives'
1817 | 'insights'
1918 | 'locks'
2019 | 'protection'
21 | 'ci'
2220
2321export const REPO_SETTINGS_SECTIONS: ReadonlyArray<{ id: RepoSettingsSection; label: string }> = [
2422 { id: 'general', get label() { return t('repo.settings.nav.general') } },
2523 { id: 'features', get label() { return t('repo.settings.nav.features') } },
2624 { id: 'topics', get label() { return t('repo.settings.nav.topics') } },
2725 { id: 'remotes', get label() { return t('repo.settings.nav.remotes') } },
28 { id: 'import', get label() { return t('repo.settings.nav.import') } },
2926 { id: 'archives', get label() { return t('repo.settings.nav.archives') } },
3027 { id: 'insights', get label() { return t('repo.settings.nav.insights') } },
3128 { id: 'locks', get label() { return t('repo.settings.nav.locks') } },
3229 { id: 'protection', get label() { return t('repo.settings.nav.protection') } },
33 { id: 'ci', get label() { return t('repo.settings.nav.ci') } },
3430]
Deleted web/src/features/repo-settings/ui/sections/ci-settings.tsx

Diff too large to display.

Modified web/src/features/repo-settings/ui/sections/features-settings.tsx View web/src/features/repo-settings/ui/sections/features-settings.tsx
@@ -6,13 +6,12 @@
66import { Notice, SectionHead } from '../parts'
77
88/** 可由设置重排序的中间页签,顺序与站点默认一致。 */
9const REORDERABLE: string[] = ['commits', 'pulls', 'branches', 'pipelines', 'releases']
9const REORDERABLE: string[] = ['commits', 'pulls', 'branches', 'releases']
1010
1111const TAB_KEYS: Record<string, MessageKey> = {
1212 commits: 'shell.repo.tabs.commits',
1313 pulls: 'shell.repo.tabs.pulls',
1414 branches: 'shell.repo.tabs.branches',
15 pipelines: 'shell.repo.tabs.pipelines',
1615 releases: 'shell.repo.tabs.releases',
1716}
1817
@@ -25,7 +24,7 @@
2524}
2625
2726/**
28 * 仓库页签配置:是否允许合并请求、流水线是否公开展示,以及中间页签的顺序。只有空间所有者能保存(服务端 PATCH repo 一律 owner-only)。
27 * 仓库页签配置:是否允许合并请求,以及中间页签的顺序。只有空间所有者能保存(服务端 PATCH repo 一律 owner-only)。
2928 */
3029export function FeaturesSettings({
3130 space,
@@ -41,7 +40,6 @@
4140 const { t } = useT()
4241 const [draft, setDraft] = useState({
4342 allow_pull_requests: repoDto.allow_pull_requests,
44 pipelines_public: repoDto.pipelines_public,
4543 })
4644 const [order, setOrder] = useState<string[]>(() => normalize(repoDto.tab_order))
4745 const [busy, setBusy] = useState(false)
@@ -51,7 +49,6 @@
5149 const savedOrder = normalize(repoDto.tab_order)
5250 const dirty =
5351 draft.allow_pull_requests !== repoDto.allow_pull_requests ||
54 draft.pipelines_public !== repoDto.pipelines_public ||
5552 order.join(',') !== savedOrder.join(',')
5653
5754 const move = (index: number, delta: -1 | 1) => {
@@ -84,11 +81,6 @@
8481 key: 'allow_pull_requests',
8582 label: t('repo.featuresSettings.allowPr'),
8683 hint: t('repo.featuresSettings.allowPrHint'),
87 },
88 {
89 key: 'pipelines_public',
90 label: t('repo.featuresSettings.pipelinesPublic'),
91 hint: t('repo.featuresSettings.pipelinesPublicHint'),
9284 },
9385 ]
9486
Deleted web/src/features/repo-settings/ui/sections/import-settings.tsx

Diff too large to display.

Modified web/src/features/repo-settings/ui/settings-tab.tsx View web/src/features/repo-settings/ui/settings-tab.tsx
@@ -2,10 +2,8 @@
22import type { RepoDetail } from '@/entities/repo'
33import { REPO_SETTINGS_SECTIONS, type RepoSettingsSection } from '../model/sections'
44import { ArchivesSettings } from './sections/archives-settings'
5import { CiSettings } from './sections/ci-settings'
65import { FeaturesSettings } from './sections/features-settings'
76import { GeneralSettings } from './sections/general-settings'
8import { ImportSettings } from './sections/import-settings'
97import { InsightsSettings } from './sections/insights-settings'
108import { LocksSettings } from './sections/locks-settings'
119import { ProtectionSettings } from './sections/protection-settings'
@@ -63,9 +61,7 @@
6361 {section === 'features' ? <FeaturesSettings space={space} repo={repo} repoDto={repoDto} onChanged={onSaved} /> : null}
6462 {section === 'topics' ? <TopicsSettings space={space} repo={repo} /> : null}
6563 {section === 'protection' ? <ProtectionSettings space={space} repo={repo} /> : null}
66 {section === 'ci' ? <CiSettings space={space} repo={repo} rev={rev} /> : null}
6764 {section === 'remotes' ? <RemotesSettings space={space} repo={repo} /> : null}
68 {section === 'import' ? <ImportSettings space={space} repo={repo} /> : null}
6965 {section === 'archives' ? (
7066 <ArchivesSettings space={space} repo={repo} repoDto={repoDto} onChanged={onSaved} />
7167 ) : null}
Deleted web/src/features/search/model/query.ts

Diff too large to display.

Deleted web/src/features/search/ui/global-search-command.tsx

Diff too large to display.

Deleted web/src/features/search/index.ts

Diff too large to display.

Deleted web/src/features/space-management/ui/import-repo-modal.tsx

Diff too large to display.

Modified web/src/features/space-management/ui/space-header.tsx View web/src/features/space-management/ui/space-header.tsx
@@ -2,7 +2,6 @@
22import { Link } from 'react-router-dom'
33import {
44 ChevronDown,
5 Download,
65 FolderGit2,
76 FolderPlus,
87 Hash,
@@ -23,7 +22,6 @@
2322 leaveSpace,
2423} from '../model/api'
2524import type { MySpace } from '../model/api'
26import { ImportRepoModal } from './import-repo-modal'
2725import { PrivacyDialog } from './privacy-dialog'
2826import { findUserByUsername, getSpaceImage, listSpaceGroups } from '../model/space-admin'
2927import { FormModal, parseList } from '@/shared/ui'
@@ -34,7 +32,6 @@
3432type Dialog =
3533 | 'invite'
3634 | 'repo'
37 | 'import'
3835 | 'board'
3936 | 'room'
4037 | 'dir'
@@ -123,7 +120,6 @@
123120 if (
124121 dialog !== 'dir' &&
125122 dialog !== 'repo' &&
126 dialog !== 'import' &&
127123 dialog !== 'board' &&
128124 dialog !== 'room'
129125 )
@@ -234,9 +230,6 @@
234230 {isOwner ? (
235231 <MenuItem icon={FolderGit2} label={t('space.menu.createRepo')} onClick={() => openDialog('repo')} />
236232 ) : null}
237 {isOwner ? (
238 <MenuItem icon={Download} label={t('space.import')} onClick={() => openDialog('import')} />
239 ) : null}
240233 {canManageChannels ? (
241234 <MenuItem icon={SquareKanban} label={t('space.menu.createBoard')} onClick={() => openDialog('board')} />
242235 ) : null}
@@ -377,14 +370,6 @@
377370 }}
378371 onClose={closeDialog}
379372 />
380 {dialog === 'import' ? (
381 <ImportRepoModal
382 space={space.name}
383 dirOptions={dirOptions}
384 onClose={closeDialog}
385 onImported={onListsChanged}
386 />
387 ) : null}
388373 <FormModal
389374 title={t('space.board.create.title')}
390375 open={dialog === 'board'}
Modified web/src/pages/workspace/model/chat-location.ts View web/src/pages/workspace/model/chat-location.ts
@@ -23,10 +23,10 @@
2323import { useSearchParams } from 'react-router-dom'
2424import type { ChatPanelKind, ChatViewState } from '@/features/chat'
2525
26const PANELS: readonly ChatPanelKind[] = ['thread', 'pins', 'threads', 'members', 'search', null]
26const PANELS: readonly ChatPanelKind[] = ['thread', 'pins', 'threads', 'members', null]
2727
2828/** Params this page owns; anything else on the URL belongs to another view. */
29const OWNED_KEYS = ['panel', 'thread', 'reply', 'edit', 'jump', 'q', 'scope']
29const OWNED_KEYS = ['panel', 'thread', 'reply', 'edit', 'jump']
3030
3131function readPanel(raw: string | null): ChatPanelKind {
3232 return (PANELS as readonly (string | null)[]).includes(raw) ? (raw as ChatPanelKind) : null
@@ -40,8 +40,6 @@
4040 replyToId: params.get('reply') ?? undefined,
4141 editId: params.get('edit') ?? undefined,
4242 jumpId: params.get('jump') ?? undefined,
43 query: params.get('q') ?? undefined,
44 scopeRoom: params.get('scope') === 'space' ? false : true,
4543 }
4644}
4745
@@ -55,8 +53,6 @@
5553 if (state.replyToId) next.set('reply', state.replyToId)
5654 if (state.editId) next.set('edit', state.editId)
5755 if (state.jumpId) next.set('jump', state.jumpId)
58 if (state.panel === 'search' && state.query) next.set('q', state.query)
59 if (state.panel === 'search' && state.scopeRoom === false) next.set('scope', 'space')
6056 return next
6157}
6258
Modified web/src/pages/workspace/model/repo-location.ts View web/src/pages/workspace/model/repo-location.ts
@@ -53,8 +53,6 @@
5353 pull: string
5454 /** pull: sub-tab inside the PR detail. */
5555 ptab: PullSubTab
56 /** pipelines: opened run id ('' = run list). */
57 run: string
5856 /** releases: opened release tag ('' = list). */
5957 release: string
6058 /** settings: active second-level section. */
@@ -73,7 +71,6 @@
7371 diff: '',
7472 pull: '',
7573 ptab: 'conversation',
76 run: '',
7774 release: '',
7875 section: 'general',
7976}
@@ -84,7 +81,6 @@
8481 'pulls',
8582 'pull',
8683 'branches',
87 'pipelines',
8884 'releases',
8985 'settings',
9086]
@@ -106,7 +102,6 @@
106102 'pull',
107103 'ptab',
108104 'section',
109 'run',
110105 'release',
111106]
112107
@@ -122,7 +117,6 @@
122117 pulls: ['tab', 'rev'],
123118 pull: ['tab', 'rev', 'pull', 'ptab'],
124119 branches: ['tab', 'rev'],
125 pipelines: ['tab', 'rev', 'run'],
126120 releases: ['tab', 'rev', 'release'],
127121 settings: ['tab', 'rev', 'section'],
128122}
@@ -146,7 +140,6 @@
146140 diff: pick(params.get('diff'), DIFF_VALUES, '') as DiffStyle | '',
147141 pull: params.get('pull') ?? '',
148142 ptab: pick(params.get('ptab'), PTAB_VALUES, 'conversation'),
149 run: params.get('run') ?? '',
150143 release: params.get('release') ?? '',
151144 section: pick(params.get('section'), SECTION_VALUES, 'general'),
152145 }
@@ -186,7 +179,6 @@
186179 if (state.diff) next.set('diff', state.diff)
187180 if (state.pull) next.set('pull', state.pull)
188181 if (state.ptab !== 'conversation') next.set('ptab', state.ptab)
189 if (state.run) next.set('run', state.run)
190182 if (state.release) next.set('release', state.release)
191183 if (state.section !== 'general') next.set('section', state.section)
192184 return next
@@ -206,7 +198,6 @@
206198 diff: owns('diff') ? state.diff : '',
207199 pull: owns('pull') ? state.pull : '',
208200 ptab: owns('ptab') ? state.ptab : 'conversation',
209 run: owns('run') ? state.run : '',
210201 section: owns('section') ? state.section : 'general',
211202 }
212203}
Modified web/src/pages/workspace/model/route.ts View web/src/pages/workspace/model/route.ts
@@ -8,7 +8,6 @@
88 | 'pulls'
99 | 'pull'
1010 | 'branches'
11 | 'pipelines'
1211 | 'releases'
1312 | 'settings'
1413
Modified web/src/pages/workspace/ui/chat/chat-view.tsx View web/src/pages/workspace/ui/chat/chat-view.tsx
@@ -76,17 +76,6 @@
7676 [navigate, onBadgesChanged],
7777 )
7878
79 // A space-wide search result may belong to another channel; `?jump=` makes the
80 // target room open its transcript windowed on exactly that message.
81 const onOpenRemoteMessage = useCallback(
82 (targetRoomId: string, messageId: string) => {
83 navigate(
84 `/app/${encodeURIComponent(spaceName)}/chat/${targetRoomId}?jump=${encodeURIComponent(messageId)}`,
85 )
86 },
87 [navigate, spaceName],
88 )
89
9079 const onRoomGone = useCallback(
9180 (reason: 'deleted' | 'left' | 'dm_closed') => {
9281 onBadgesChanged()
@@ -124,7 +113,6 @@
124113 onUnreadChanged={onBadgesChanged}
125114 onRoomGone={onRoomGone}
126115 onDmOpened={onDmOpened}
127 onOpenRemoteMessage={onOpenRemoteMessage}
128116 />
129117 </ChatLayout>
130118 )
Modified web/src/pages/workspace/ui/layout/repo-layout.tsx View web/src/pages/workspace/ui/layout/repo-layout.tsx
@@ -1,5 +1,5 @@
11import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
2import { Activity, ChevronDown, Code, File, Folder, FolderGit2, GitBranch, GitCommitHorizontal, GitPullRequest, Info, MoreHorizontal, Settings, Tag, type LucideIcon } from 'lucide-react'
2import { ChevronDown, Code, File, Folder, FolderGit2, GitBranch, GitCommitHorizontal, GitPullRequest, Info, MoreHorizontal, Settings, Tag, type LucideIcon } from 'lucide-react'
33import { useT, type MessageKey } from '@/shared/i18n'
44import { AppShell, ShellTopBar, type AppShellProps } from '@/shared/ui'
55import type { RepoTabId } from '../../model/route'
@@ -54,13 +54,12 @@
5454/** 仓库设置可配置的页签:开关到底是否可见、以及中间页签的顺序。 */
5555export interface RepoTabOptions {
5656 allowPullRequests: boolean
57 pipelinesVisible: boolean
58 /** 中间页签顺序(提交/合并请求/分支/流水线/发布);空 = 默认。 */
57 /** 中间页签顺序(提交/合并请求/分支/发布);空 = 默认。 */
5958 order: readonly string[]
6059}
6160
6261export function RepoLayout({
63 title = 'gitdata / synchost',
62 title = 'gitdata',
6463 topic,
6564 actions,
6665 tabs,
@@ -131,7 +130,6 @@
131130 ['commits', 'shell.repo.tabs.commits'],
132131 ['pulls', 'shell.repo.tabs.pulls'],
133132 ['branches', 'shell.repo.tabs.branches'],
134 ['pipelines', 'shell.repo.tabs.pipelines'],
135133 ['releases', 'shell.repo.tabs.releases'],
136134 ['settings', 'shell.repo.tabs.settings'],
137135]
@@ -142,7 +140,7 @@
142140const TAB_LABEL_KEYS = new Map<RepoTabId, MessageKey>(tabLabels)
143141
144142/** 可由仓库设置重排序的中间页签(代码固定最前、设置固定最后)。 */
145const REORDERABLE_TABS: RepoTabId[] = ['commits', 'pulls', 'branches', 'pipelines', 'releases']
143const REORDERABLE_TABS: RepoTabId[] = ['commits', 'pulls', 'branches', 'releases']
146144
147145/** 归一化仓库设置里的顺序:未知/重复项忽略,缺失项按默认顺序补齐。 */
148146function orderTabs(order: readonly string[] | undefined): RepoTabId[] {
@@ -172,7 +170,6 @@
172170 pulls: GitPullRequest,
173171 pull: GitPullRequest,
174172 branches: GitBranch,
175 pipelines: Activity,
176173 releases: Tag,
177174 settings: Settings,
178175}
@@ -231,11 +228,7 @@
231228 // 页签条 = 代码(固定)+ 仓库设置里开关/排序过的中间页签 + 设置(按权限裁剪)。
232229 const labels = useMemo<Array<[RepoTabId, MessageKey]>>(() => {
233230 const middle = orderTabs(tabOptions?.order).filter(id =>
234 id === 'pulls'
235 ? (tabOptions?.allowPullRequests ?? true)
236 : id === 'pipelines'
237 ? (tabOptions?.pipelinesVisible ?? true)
238 : true,
231 id === 'pulls' ? (tabOptions?.allowPullRequests ?? true) : true,
239232 )
240233 const ids: RepoTabId[] = ['code', ...middle, ...(canAdmin ? (['settings'] as RepoTabId[]) : [])]
241234 return ids.map(id => [id, TAB_LABEL_KEYS.get(id) ?? 'shell.repo.tabs.code'])
Modified web/src/pages/workspace/ui/repo/repo-view.tsx View web/src/pages/workspace/ui/repo/repo-view.tsx
@@ -16,7 +16,6 @@
1616import { CommitsTab, CommitView } from '@/features/repo-history'
1717import { PullsTab, PullDetail } from '@/features/pull-request'
1818import { SettingsTab } from '@/features/repo-settings'
19import { PipelinesView } from '@/features/repo-pipelines'
2019import { ReleasesTab } from '@/features/repo-releases'
2120import { RefPicker } from './ref-picker'
2221import { RepoCloneMenu } from './clone-menu'
@@ -74,7 +73,7 @@
7473 browseRev,
7574 normalizeForRepo,
7675 } = useRepoViewLocation()
77 const { tab, rev, path, file: filePath, oid, line, commit, firstParent, diff, pull: pullRef, ptab, run, release, section } = view
76 const { tab, rev, path, file: filePath, oid, line, commit, firstParent, diff, pull: pullRef, ptab, release, section } = view
7877 const file = filePath ? { path: filePath, oid: oid || undefined } : null
7978 const [mode, setMode] = useState<RenderMode>('html')
8079 // Clone addresses come from the backend — never guessed from window.location.
@@ -84,9 +83,6 @@
8483 // 所以按空间权限上下文收敛到所有者。
8584 const ownerUid = mySpace?.space.owner?.user_id ?? (mySpace?.space as unknown as { owner_uid?: string } | undefined)?.owner_uid
8685 const canManageRepo = Boolean(ownerUid && viewerId && ownerUid === viewerId)
87 // 「公开展示」关闭的页签仅空间成员(含 owner)可见;允许 PR 关闭则对所有人隐藏。
88 const isSpaceMember = Boolean(mySpace)
89
9086 // PR 详情面板的展开/收回动画:打开时向下展开,关闭时向上收拢 300ms 后再卸载,
9187 // 因此面板在关闭动画播放期间保持挂载(`detailRef` 在动画结束后才清空)。
9288 const detailOpen = tab === 'pull' && Boolean(pullRef)
@@ -144,9 +140,8 @@
144140 useEffect(() => {
145141 if (!repo) return
146142 const prHidden = !repo.allow_pull_requests && (tab === 'pulls' || tab === 'pull')
147 const pipelinesHidden = !(repo.pipelines_public || isSpaceMember || canManageRepo) && tab === 'pipelines'
148 if (prHidden || pipelinesHidden) patch({ tab: 'code' }, { replace: true })
149 }, [repo, tab, isSpaceMember, canManageRepo, patch])
143 if (prHidden) patch({ tab: 'code' }, { replace: true })
144 }, [repo, tab, patch])
150145
151146 if (repoState.loading && !repo) {
152147 return (
@@ -207,7 +202,6 @@
207202 showAside={tab === 'code'}
208203 tabOptions={{
209204 allowPullRequests: repo.allow_pull_requests,
210 pipelinesVisible: repo.pipelines_public || isSpaceMember || canManageRepo,
211205 order: repo.tab_order,
212206 }}
213207 aside={
@@ -322,16 +316,6 @@
322316 onCheckout={setRev}
323317 onBrowse={browseRev}
324318 onChanged={() => setTick(t => t + 1)}
325 />
326 ) : null}
327 {tab === 'pipelines' ? (
328 <PipelinesView
329 space={spaceName}
330 repo={repoName}
331 defaultBranch={repo.default_branch}
332 canWrite={canManageRepo}
333 runId={run}
334 onSelectRun={runId => patch({ run: runId })}
335319 />
336320 ) : null}
337321 {tab === 'releases' ? (
Modified web/src/pages/workspace/ui/asset-menu.tsx View web/src/pages/workspace/ui/asset-menu.tsx
@@ -278,7 +278,7 @@
278278 >
279279 {items.length === 0 ? (
280280 <p className="px-3 py-6 text-center text-[12.5px] text-shell-ink-muted">
281 {loading ? t('search.command.loading') : t('shell.search.empty')}
281 {loading ? t('common.action.loading') : t('shell.search.empty')}
282282 </p>
283283 ) : (
284284 rows.map(row =>
Modified web/src/pages/workspace/ui/workspace-shell.tsx View web/src/pages/workspace/ui/workspace-shell.tsx
@@ -20,7 +20,6 @@
2020import { SpaceRail } from './space-rail'
2121import { UserPanel } from './user-panel'
2222import { UserSettingsModal } from '@/features/user-settings'
23import { GlobalSearchCommand, type SearchScope } from '@/features/search'
2423import { ExploreView } from '@/features/space-discovery'
2524
2625/**
@@ -149,27 +148,6 @@
149148
150149 const personal = spaceName === '@me'
151150 const canOrganize = currentMySpace?.permissions?.includes('manage_channels') ?? false
152 /**
153 * 全局搜索默认继承的当前位置。只带「现在真正在看的那一层」:仓库页限定到
154 * 仓库,频道页限定到频道,看板页限定到看板——空间本身不作为约束,因为在
155 * 空间首页时用户想要的通常是「在这个空间里找」,而它已经由 `spaceName` 表达;
156 * `@me` / explore 这类没有空间作用域的页面则不传。
157 */
158 const searchScope: SearchScope | undefined = exploring
159 ? undefined
160 : repoName
161 ? {
162 spaceName,
163 repoName,
164 label: `${spaceName}/${repoName}`,
165 }
166 : roomId
167 ? { spaceName, roomId, label: room?.name || dm?.name || roomId }
168 : boardId
169 ? { spaceName, boardId, label: board?.name || boardId }
170 : spaceName && spaceName !== '@me'
171 ? { spaceName, label: spaceName }
172 : undefined
173151 const shell = {
174152 // Always the real rail: fabricated placeholder tiles must not flash
175153 // while `GET /me/spaces` is still in flight.
@@ -272,7 +250,6 @@
272250 <NotifyToaster />
273251
274252 <VoiceCallBar />
275 <GlobalSearchCommand scope={searchScope} />
276253 </>
277254 )
278255 }
@@ -288,7 +265,6 @@
288265 <NotifyToaster />
289266
290267 <VoiceCallBar />
291 <GlobalSearchCommand scope={searchScope} />
292268 </>
293269 )
294270 }
@@ -310,7 +286,6 @@
310286 <NotifyToaster />
311287
312288 <VoiceCallBar />
313 <GlobalSearchCommand scope={searchScope} />
314289 </>
315290 )
316291 }
@@ -331,7 +306,6 @@
331306 <NotifyToaster />
332307
333308 <VoiceCallBar />
334 <GlobalSearchCommand scope={searchScope} />
335309 </>
336310 )
337311 }
@@ -346,7 +320,6 @@
346320 <NotifyToaster />
347321
348322 <VoiceCallBar />
349 <GlobalSearchCommand scope={searchScope} />
350323 </>
351324 )
352325 }
@@ -370,7 +343,6 @@
370343 <NotifyToaster />
371344
372345 <VoiceCallBar />
373 <GlobalSearchCommand scope={searchScope} />
374346 </>
375347 )
376348 }
@@ -387,7 +359,6 @@
387359 <NotifyToaster />
388360
389361 <VoiceCallBar />
390 <GlobalSearchCommand scope={searchScope} />
391362 </>
392363 )
393364}
Modified web/src/shared/api/apiDefinitions.ts View web/src/shared/api/apiDefinitions.ts
@@ -204,7 +204,6 @@
204204 'chat.get_emoji': ['GET', '/chat/spaces/{space_name}/emojis/{emoji_id}'],
205205 'chat.delete_emoji': ['DELETE', '/chat/spaces/{space_name}/emojis/{emoji_id}'],
206206 'chat.emoji_image': ['GET', '/chat/spaces/{space_name}/emojis/{emoji_id}/image'],
207 'chat.search_messages': ['GET', '/chat/spaces/{space_name}/messages/search'],
208207 'chat.list_space_presences': ['GET', '/chat/spaces/{space_name}/presences'],
209208 'chat.list_space_read_states': ['GET', '/chat/spaces/{space_name}/read-states'],
210209 'chat.list_space_rooms': ['GET', '/chat/spaces/{space_name}/rooms'],
@@ -218,12 +217,6 @@
218217 'chat.mark_thread_read': ['PUT', '/chat/threads/{thread_id}/read-state'],
219218 'chat.get_presence': ['GET', '/chat/users/{user_id}/presence'],
220219 'chat.voice_config': ['GET', '/chat/voice/config'],
221 'imports.list_imports': ['GET', '/imports/{space_name}'],
222 'imports.create_import': ['POST', '/imports/{space_name}'],
223 'imports.probe_import': ['POST', '/imports/{space_name}/probe'],
224 'imports.get_import': ['GET', '/imports/{space_name}/{job_id}'],
225 'imports.cancel_import': ['POST', '/imports/{space_name}/{job_id}/cancel'],
226 'imports.retry_import': ['POST', '/imports/{space_name}/{job_id}/retry'],
227220 'issues.create_issue_attachment': ['POST', '/issues/attachments'],
228221 'issues.delete_issue_attachment': ['DELETE', '/issues/attachments/{attachment_id}'],
229222 'issues.download_issue_attachment': ['GET', '/issues/attachments/{attachment_id}/file'],
@@ -405,8 +398,6 @@
405398 'diffs.diff_for_commit': ['GET', '/repos/{space_name}/{repo_name}/diff/for-commit/{commit}'],
406399 'diffs.range_diff': ['GET', '/repos/{space_name}/{repo_name}/diff/range'],
407400 'diffs.diff_raw': ['GET', '/repos/{space_name}/{repo_name}/diff/raw'],
408 'pipelines.environments': ['GET', '/repos/{space_name}/{repo_name}/environments'],
409 'pipelines.upsert_environment': ['PUT', '/repos/{space_name}/{repo_name}/environments/{name}'],
410401 'hooks.execute_script': ['POST', '/repos/{space_name}/{repo_name}/hooks/{script}'],
411402 'blobs.list_lfs_pointers': ['GET', '/repos/{space_name}/{repo_name}/lfs-pointers'],
412403 'lfs.list_locks': ['GET', '/repos/{space_name}/{repo_name}/lfs/locks'],
@@ -416,22 +407,6 @@
416407 'lfs.unlock_by_id': ['DELETE', '/repos/{space_name}/{repo_name}/lfs/locks/{lock_id}'],
417408 'patches.patch_generate': ['GET', '/repos/{space_name}/{repo_name}/patch/generate'],
418409 'logs.path_log': ['GET', '/repos/{space_name}/{repo_name}/path-log'],
419 'pipelines.list_runs': ['GET', '/repos/{space_name}/{repo_name}/pipelines'],
420 'pipelines.download_actions_artifact': [
421 'GET',
422 '/repos/{space_name}/{repo_name}/pipelines/actions-artifacts/{artifact_id}/download'
423 ],
424 'pipelines.download_artifact': ['GET', '/repos/{space_name}/{repo_name}/pipelines/artifacts/{artifact_id}/download'],
425 'pipelines.dispatch': ['POST', '/repos/{space_name}/{repo_name}/pipelines/dispatches'],
426 'pipelines.trigger': ['POST', '/repos/{space_name}/{repo_name}/pipelines/trigger'],
427 'pipelines.list_workflows': ['GET', '/repos/{space_name}/{repo_name}/pipelines/workflows'],
428 'pipelines.get_run': ['GET', '/repos/{space_name}/{repo_name}/pipelines/{run_id}'],
429 'pipelines.approvals': ['GET', '/repos/{space_name}/{repo_name}/pipelines/{run_id}/approvals'],
430 'pipelines.review': ['POST', '/repos/{space_name}/{repo_name}/pipelines/{run_id}/approvals/{review_id}'],
431 'pipelines.list_artifacts': ['GET', '/repos/{space_name}/{repo_name}/pipelines/{run_id}/artifacts'],
432 'pipelines.cancel_run': ['POST', '/repos/{space_name}/{repo_name}/pipelines/{run_id}/cancel'],
433 'pipelines.read_logs': ['GET', '/repos/{space_name}/{repo_name}/pipelines/{run_id}/jobs/{job_id}/logs'],
434 'pipelines.rerun': ['POST', '/repos/{space_name}/{repo_name}/pipelines/{run_id}/rerun'],
435410 'pulls.list_pulls': ['GET', '/repos/{space_name}/{repo_name}/pulls'],
436411 'pulls.create_pull': ['POST', '/repos/{space_name}/{repo_name}/pulls'],
437412 'pulls.list_label_options': ['GET', '/repos/{space_name}/{repo_name}/pulls/label-options'],
@@ -493,9 +468,6 @@
493468 'remotes.upsert_remote': ['PUT', '/repos/{space_name}/{repo_name}/remotes/{name}'],
494469 'remotes.remove': ['DELETE', '/repos/{space_name}/{repo_name}/remotes/{name}'],
495470 'commits.rev_parse': ['GET', '/repos/{space_name}/{repo_name}/rev/{revision}'],
496 'pipelines.list_repo_secrets': ['GET', '/repos/{space_name}/{repo_name}/secrets'],
497 'pipelines.upsert_repo_secret': ['PUT', '/repos/{space_name}/{repo_name}/secrets/{name}'],
498 'pipelines.delete_repo_secret': ['DELETE', '/repos/{space_name}/{repo_name}/secrets/{name}'],
499471 'signatures.verify_commit': ['GET', '/repos/{space_name}/{repo_name}/signatures/commit/{sha}'],
500472 'signatures.verify_tag': ['GET', '/repos/{space_name}/{repo_name}/signatures/tag/{name}'],
501473 'repos.star_repo': ['PUT', '/repos/{space_name}/{repo_name}/star'],
@@ -503,8 +475,6 @@
503475 'repos.list_stargazers': ['GET', '/repos/{space_name}/{repo_name}/stargazers'],
504476 'submodules.submodule_list': ['GET', '/repos/{space_name}/{repo_name}/submodules'],
505477 'submodules.submodule_resolve': ['GET', '/repos/{space_name}/{repo_name}/submodules/resolve'],
506 'search.repo_symbols': ['GET', '/repos/{space_name}/{repo_name}/symbols'],
507 'search.symbol_references': ['GET', '/repos/{space_name}/{repo_name}/symbols/{symbol_name}/references'],
508478 'tag_protection.list_policies': ['GET', '/repos/{space_name}/{repo_name}/tag-protection'],
509479 'tag_protection.create_policy': ['POST', '/repos/{space_name}/{repo_name}/tag-protection'],
510480 'tag_protection.get_policy': ['GET', '/repos/{space_name}/{repo_name}/tag-protection/{policy_id}'],
@@ -528,12 +498,8 @@
528498 'repos.remove_topic_from_repo': ['DELETE', '/repos/{space_name}/{repo_name}/topics/{topic_name}'],
529499 'trees.get_tree': ['GET', '/repos/{space_name}/{repo_name}/tree/{revision}'],
530500 'repos.unarchive_repo': ['POST', '/repos/{space_name}/{repo_name}/unarchive'],
531 'pipelines.variables': ['GET', '/repos/{space_name}/{repo_name}/variables'],
532 'pipelines.upsert_variable': ['PUT', '/repos/{space_name}/{repo_name}/variables/{name}'],
533 'pipelines.delete_variable': ['DELETE', '/repos/{space_name}/{repo_name}/variables/{name}'],
534501 'repos.upsert_watch': ['PUT', '/repos/{space_name}/{repo_name}/watch'],
535502 'repos.list_watchers': ['GET', '/repos/{space_name}/{repo_name}/watchers'],
536 'search.global_search': ['GET', '/search'],
537503 'space.list_spaces': ['GET', '/spaces'],
538504 'space.create_space': ['POST', '/spaces'],
539505 'space.explore_spaces': ['GET', '/spaces/explore'],
@@ -563,8 +529,6 @@
563529 'space.set_dir_user_overwrite': ['PUT', '/spaces/{space_name}/dir/{dir_id}/overwrites/users/{user_id}'],
564530 'space.remove_dir_user_overwrite': ['DELETE', '/spaces/{space_name}/dir/{dir_id}/overwrites/users/{user_id}'],
565531 'space.move_dir_node': ['PATCH', '/spaces/{space_name}/dir/{dir_id}/position'],
566 'pipelines.list_environments': ['GET', '/spaces/{space_name}/environments'],
567 'pipelines.list_deployments': ['GET', '/spaces/{space_name}/environments/{name}/deployments'],
568532 'space.list_groups': ['GET', '/spaces/{space_name}/groups'],
569533 'space.create_group': ['POST', '/spaces/{space_name}/groups'],
570534 'space.get_group': ['GET', '/spaces/{space_name}/groups/{group_id}'],
@@ -597,7 +561,6 @@
597561 'space.update_member': ['PATCH', '/spaces/{space_name}/members/{user_id}'],
598562 'space.assign_group': ['POST', '/spaces/{space_name}/members/{user_id}/groups'],
599563 'space.unassign_group': ['DELETE', '/spaces/{space_name}/members/{user_id}/groups/{group_id}'],
600 'pipelines.list_space_secrets': ['GET', '/spaces/{space_name}/secrets'],
601564 'topics.list_topics': ['GET', '/topics'],
602565 'topics.create_topic': ['POST', '/topics'],
603566 'topics.get_topic': ['GET', '/topics/{name}'],
Modified web/src/shared/api/globals.d.ts View web/src/shared/api/globals.d.ts

Diff too large to display.

Modified web/src/shared/i18n/messages/en/chat.ts View web/src/shared/i18n/messages/en/chat.ts
@@ -71,8 +71,6 @@
7171 'chat.voice.members': 'Members online',
7272 'chat.voice.empty': 'Nobody else is in this channel yet',
7373 'chat.voice.membersCount': '{count} members are already here',
74 'chat.search.placeholder': 'Search messages',
75 'chat.search.empty': 'No matching messages',
7674 'chat.member.list': 'Members',
7775 'chat.member.invite': 'Invite members',
7876 'chat.member.online': 'Online',
@@ -237,12 +235,6 @@
237235 'chat.member.listFailed': 'Space members could not be read',
238236
239237 // Search panel.
240 'chat.search.aria': 'Search chat messages',
241 'chat.search.inputPlaceholder': 'Type a keyword to search',
242 'chat.search.onlyRoom': 'This channel only (clear to search the whole space)',
243 'chat.search.unsupported': 'There is nothing searchable here: direct messages and the personal scope have no full-text search.',
244 'chat.search.emptyFor': 'No messages match "{query}".',
245 'chat.search.hint': 'Type a keyword to start searching',
246238
247239 // Attachments.
248240 'chat.attachment.unknownType': 'Unknown type',
Modified web/src/shared/i18n/messages/en/issues.ts View web/src/shared/i18n/messages/en/issues.ts
@@ -465,7 +465,6 @@
465465 'pull.checks.empty': 'No check data.',
466466 'pull.checks.versionEmpty': 'Revision {sha} has no pipeline runs or commit statuses.',
467467 'pull.checks.version': 'Revision',
468 'pull.checks.pipeline': 'Pipeline #{id}',
469468 'pull.checks.details': 'Details',
470469 'pull.commits.loading': 'Loading commits…',
471470 'pull.commits.errorList': 'Commit list',
Modified web/src/shared/i18n/messages/en/misc.ts View web/src/shared/i18n/messages/en/misc.ts
@@ -10,16 +10,6 @@
1010 'notifications.filter.mentions': 'Mentions',
1111 'notifications.delete': 'Delete notification',
1212 'notifications.deleted': 'Notification deleted.',
13 'search.requestFailed': 'Search failed',
14 'search.title': 'Search',
15 'search.placeholder': 'Search code, issues, messages…',
16 'search.results': 'Search results',
17 'search.empty': 'No matching results',
18 'search.failed': 'Search failed. Please try again later.',
19 'search.tab.code': 'Code',
20 'search.tab.issues': 'Issues',
21 'search.tab.messages': 'Messages',
22 'search.tab.repos': 'Repositories',
2313 'notifications.sub': 'Invites, assignments and system reminders live here; mentions and replies take you straight back to their channel or issue.',
2414 'notifications.filter': 'Notification filter',
2515 'notifications.emptyUnread': 'No unread notifications.',
@@ -46,22 +36,4 @@
4636 'notifications.bellUnread': 'Notifications, {count} unread',
4737 'notifications.region': 'System notifications',
4838 'notifications.closeToast': 'Dismiss notification',
49 'search.command.placeholder': 'Search code, symbols, issues, pull requests and messages…',
50 'search.command.open': 'Open global search',
51 'search.command.dialog': 'Global search',
52 'search.command.close': 'Close search',
53 'search.command.types': 'Search types',
54 'search.command.hint': 'Type a keyword to start searching',
55 'search.command.needText': 'Add a keyword — the filters are ready',
56 'search.command.filters': 'Active search filters',
57 'search.command.scope': 'Current scope',
58 'search.command.scopeFrom': 'This page:',
59 'search.command.removeScope': 'Search everything instead (remove {scope})',
60 'search.command.removeFilter': 'Remove filter {filter}',
61 'search.command.loading': 'Searching…',
62 'search.kind.code': 'Code',
63 'search.kind.symbol': 'Symbols',
64 'search.kind.issue': 'Issues',
65 'search.kind.pull': 'Pull requests',
66 'search.kind.message': 'Messages',
6739} as const
Modified web/src/shared/i18n/messages/en/repo.ts View web/src/shared/i18n/messages/en/repo.ts
@@ -1,15 +1,14 @@
1/** Repository surfaces: browse, history, branches, releases, pipelines, settings. */
1/** Repository surfaces: browse, history, branches, releases, settings. */
22export const repo = {
33 'repo.tabs.code': 'Code',
44 'repo.tabs.commits': 'Commits',
55 'repo.tabs.branches': 'Branches',
66 'repo.tabs.tags': 'Tags',
77 'repo.tabs.releases': 'Releases',
8 'repo.tabs.pipelines': 'Pipelines',
98 'repo.tabs.settings': 'Settings',
109 'repo.tabs.pulls': 'Pull requests',
1110 'repo.unknownPath': '(unknown path)',
12 'repo.import.failed': 'The import request failed', 'repo.empty': 'This repository has no files',
11 'repo.empty': 'This repository has no files',
1312 'repo.loading': 'Loading repository…',
1413 'repo.notFound': 'That repository does not exist or has been deleted.',
1514 'repo.branch.default': 'Default branch',
@@ -63,16 +62,6 @@
6362 'repo.release.deleted': 'Release deleted.',
6463 'repo.release.empty': 'No releases yet',
6564 'repo.release.downloads': 'Downloads',
66 'repo.pipeline.run': 'Run pipeline',
67 'repo.pipeline.trigger': 'Trigger manually',
68 'repo.pipeline.cancel': 'Cancel run',
69 'repo.pipeline.rerun': 'Re-run',
70 'repo.pipeline.triggered': 'Pipeline triggered.',
71 'repo.pipeline.cancelled': 'Pipeline cancelled.',
72 'repo.pipeline.empty': 'No pipeline runs yet',
73 'repo.pipeline.jobs': 'Jobs',
74 'repo.pipeline.logs': 'Logs',
75 'repo.pipeline.artifacts': 'Artifacts',
7665 'repo.settings.general': 'General',
7766 'repo.settings.name': 'Repository name',
7867 'repo.settings.description': 'Description',
@@ -94,13 +83,6 @@
9483 'repo.lfs.enabled': 'LFS enabled',
9584 'repo.lfs.patterns': 'Tracked patterns',
9685 'repo.lfs.objects': 'LFS objects',
97 'repo.webhook.add': 'New webhook',
98 'repo.webhook.url': 'Payload URL',
99 'repo.webhook.secret': 'Signing secret',
100 'repo.webhook.events': 'Events',
101 'repo.webhook.created': 'Webhook created.',
102 'repo.webhook.deleted': 'Webhook deleted.',
103 'repo.webhook.empty': 'No webhooks yet',
10486 'repo.action.add': 'Add',
10587 'repo.action.browseCode': 'Browse code',
10688 'repo.action.confirmDelete': 'Confirm delete',
@@ -315,46 +297,6 @@
315297 'repo.release.tagAge': '({age})',
316298 'repo.release.titleOptional': 'Title (optional)',
317299 'repo.release.saveAsDraft': 'Save as a draft first',
318 'repo.pipeline.heading': 'Pipelines',
319 'repo.pipeline.subtitle': 'Driven by GitHub Actions-compatible workflows in .github/workflows/; runners pick up jobs through the scheduler.',
320 'repo.pipeline.state.pending': 'Waiting on dependencies',
321 'repo.pipeline.state.claimed': 'Claimed',
322 'repo.pipeline.state.timedOut': 'Timed out',
323 'repo.pipeline.state.cancelRequested': 'Cancelling',
324 'repo.pipeline.state.ready': 'Ready',
325 'repo.pipeline.state.uploading': 'Uploading',
326 'repo.pipeline.runsLoading': 'Loading runs…',
327 'repo.pipeline.runsEmpty': 'No runs yet. Push a commit containing .github/workflows/*.yml, or trigger one here.',
328 'repo.pipeline.triggerFailed': 'Failed to trigger',
329 'repo.pipeline.triggerTitle': 'Manually trigger workflow_dispatch',
330 'repo.pipeline.ref': 'Branch or tag',
331 'repo.pipeline.workflow': 'Workflow',
332 'repo.pipeline.workflowsFailed': 'Failed to load workflows',
333 'repo.pipeline.noDispatchable': 'No manually triggerable workflows at this revision (one must declare on.workflow_dispatch).',
334 'repo.pipeline.triggering': 'Triggering…',
335 'repo.pipeline.triggerConfirm': 'Trigger',
336 'repo.pipeline.detailLoading': 'Loading run details…',
337 'repo.pipeline.attempt': 'Attempt {count}',
338 'repo.pipeline.noLogs': '(no log output yet)',
339 'repo.pipeline.runNumber': '#{number}',
340 'repo.pipeline.matrix': 'Matrix',
341 'repo.pipeline.outputs': 'Outputs',
342 'repo.pipeline.annotations': 'Annotations',
343 'repo.pipeline.summary': 'Summary',
344 'repo.pipeline.emptyOutputs': 'No outputs',
345 'repo.pipeline.emptyAnnotations': 'No annotations',
346 'repo.pipeline.inputs': 'Inputs',
347 'repo.pipeline.inputRequired': 'Required',
348 'repo.pipeline.approvals': 'Environment approvals',
349 'repo.pipeline.noApprovals': 'No environment approvals for this run',
350 'repo.pipeline.approvalPending': 'Pending',
351 'repo.pipeline.approvalApproved': 'Approved',
352 'repo.pipeline.approvalRejected': 'Rejected',
353 'repo.pipeline.approve': 'Approve',
354 'repo.pipeline.reject': 'Reject',
355 'repo.pipeline.approvalFailed': 'Approval action failed',
356 'repo.pipeline.booleanTrue': 'Yes',
357 'repo.pipeline.booleanFalse': 'No',
358300 'repo.comment.unknownUser': 'Unknown user',
359301 'repo.comment.resolved': 'Resolved',
360302 'repo.comment.outdated': 'Outdated',
@@ -394,8 +336,6 @@
394336 'repo.featuresSettings.sub': 'Control which tabs the repository strip shows and in what order. Space owners only.',
395337 'repo.featuresSettings.allowPr': 'Allow pull requests',
396338 'repo.featuresSettings.allowPrHint': 'When off, the Pull requests tab is hidden for everyone and creating pull requests is rejected.',
397 'repo.featuresSettings.pipelinesPublic': 'Publicly show pipelines',
398 'repo.featuresSettings.pipelinesPublicHint': 'When off, the Pipelines tab is visible to space members only.',
399339 'repo.featuresSettings.orderTitle': 'Tab order',
400340 'repo.featuresSettings.orderHint': 'Reorder Commits / Pull requests / Branches / Pipelines / Releases. Code and Settings stay pinned.',
401341 'repo.featuresSettings.moveUp': 'Move {name} up',
@@ -573,44 +513,6 @@
573513 'repo.archives.factSize': 'Size',
574514 'repo.archives.openDownload': 'Download in a new tab',
575515 'repo.archives.linkNote': 'Links are short-lived; click “Reuse an existing archive” to get a new one.',
576 'repo.ci.secretSaved': 'Secret saved',
577 'repo.ci.secretDeleted': 'Secret deleted',
578 'repo.ci.deleteFailed': 'Failed to delete',
579 'repo.ci.sub': 'Workflow definitions come from .github/workflows/ on the default branch (or current revision); runners pick up jobs through the scheduler.',
580 'repo.ci.workflows': 'Workflows',
581 'repo.ci.workflowsLoading': 'Reading .github/workflows/…',
582 'repo.ci.workflowsEmpty': 'This revision has no .github/workflows/*.yml definitions.',
583 'repo.ci.valid': 'Valid',
584 'repo.ci.invalid': 'Parse failed',
585 'repo.ci.dispatchable': 'Manually triggerable',
586 'repo.ci.secretsTitle': 'Repository secrets',
587 'repo.ci.secretsFailed': 'Failed to load secrets',
588 'repo.ci.updatedAt': 'Updated {time}',
589 'repo.ci.secretsEmpty': 'No secrets configured yet.',
590 'repo.ci.upsertSecret': 'Add / overwrite a secret',
591 'repo.ci.upsertSecretHint': 'The value is written to the database only (AES-GCM encrypted) and never read back.',
592 'repo.ci.secretNamePlaceholder': 'Name (letters, digits, _, -)',
593 'repo.ci.value': 'Value',
594 'repo.ci.secretValuePlaceholder': 'Secret value',
595 'repo.ci.saveSecret': 'Save secret',
596 'repo.import.state.processing': 'In progress',
597 'repo.import.state.succeeded': 'Completed',
598 'repo.import.stage.probe': 'Probing the source repository',
599 'repo.import.stage.clone': 'Mirroring refs',
600 'repo.import.stage.sync': 'Syncing branches and commits',
601 'repo.import.stage.finalize': 'Finalizing stats and authorship',
602 'repo.import.stage.done': 'Done',
603 'repo.import.sub': 'Import jobs that mirrored this repository from GitHub / GitLab / Gitea. Start a new import from the community menu’s “Import repository”.',
604 'repo.import.empty': 'This repository has no import jobs.',
605 'repo.import.attempts': '· {count} attempts',
606 'repo.import.stats': 'Branches {branches} · tags {tags} · commits {commits} · size {size}',
607 'repo.import.statsDefault': '· default branch {branch}',
608 'repo.import.canceled': 'Import job cancelled.',
609 'repo.import.confirmCancel': 'Confirm cancel',
610 'repo.import.requeued': 'Import job requeued.',
611 'repo.import.token': 'Retry token',
612 'repo.import.tokenHint': 'The server clears the token once a job finishes; private-repository retries must supply it again.',
613 'repo.import.tokenPlaceholder': 'Leave blank to retry anonymously',
614516 'repo.remotes.sub': 'Git remotes configured for this repository. URLs must use https://, ssh:// or git:// and must not point at localhost or private addresses.',
615517 'repo.remotes.loading': 'Loading remotes…',
616518 'repo.remotes.empty': 'No remotes configured yet.',
Modified web/src/shared/i18n/messages/en/shell.ts View web/src/shared/i18n/messages/en/shell.ts
@@ -47,7 +47,6 @@
4747 'shell.repo.tabs.commits': 'Commits',
4848 'shell.repo.tabs.pulls': 'Pull requests',
4949 'shell.repo.tabs.branches': 'Branches',
50 'shell.repo.tabs.pipelines': 'Pipelines',
5150 'shell.repo.tabs.releases': 'Releases',
5251 'shell.repo.tabs.settings': 'Settings',
5352 'shell.repo.tabs.more': 'More',
Modified web/src/shared/i18n/messages/en/space.ts View web/src/shared/i18n/messages/en/space.ts
@@ -1,15 +1,9 @@
1/** Space management: creation, import, members, roles, invites, settings. */
1/** Space management: creation, members, roles, invites, settings. */
22export const space = {
33 'space.create': 'New space',
44 'space.create.name': 'Space name',
55 'space.create.slug': 'URL slug',
66 'space.create.description': 'Space description',
7 'space.import': 'Import repository',
8 'space.import.url': 'Upstream repository URL',
9 'space.import.name': 'Repository name',
10 'space.import.started': 'Import task created.',
11 'space.import.disabled': 'Repository import is currently disabled.',
12 'space.import.upstreamFailed': 'Could not reach the upstream repository. Check the URL or credentials.',
137 'space.settings.title': 'Space settings',
148 'space.settings.general': 'General',
159 'space.settings.members': 'Members',
@@ -90,63 +84,6 @@
9084 'space.create.descriptionPlaceholder': 'What is this space for?',
9185 'space.create.private': 'Private space',
9286 'space.create.creating': 'Creating…',
93 'space.import.title': 'Import an external repository',
94 'space.import.progress': 'Import progress · {repo}',
95 'space.import.subtitleForm': 'Mirror a repository from GitHub / GitLab / Gitea: every branch, tag and commit, plus its description, default branch and visibility.',
96 'space.import.subtitleConfirm': 'Review the probe result, then create the repository and start the asynchronous mirror.',
97 'space.import.subtitleProgress': 'synchost runs the job asynchronously; you can close this window and check back later.',
98 'space.import.provider': 'Source platform',
99 'space.import.source': 'Repository URL or owner/repo',
100 'space.import.base': 'Instance URL · For self-hosted',
101 'space.import.baseHint': 'An intranet or localhost instance must first be added to {field} by an administrator.',
102 'space.import.token': 'Access token · Required for private repositories',
103 'space.import.tokenPlaceholder': 'Leave empty to import a public repository anonymously',
104 'space.import.localName': 'Local repository name · Optional',
105 'space.import.localNamePlaceholder': 'Leave empty to keep the source name',
106 'space.import.dir': 'Parent folder',
107 'space.import.root': 'Root',
108 'space.import.description': 'Description · Optional',
109 'space.import.descriptionPlaceholder': 'Leave empty to keep the source description',
110 'space.import.private': 'Private repository (leave empty to keep the source visibility)',
111 'space.import.enableLfs': 'Enable Git LFS',
112 'space.import.sourcePlaceholder.github': 'https://github.com/octocat/hello-world or octocat/hello-world',
113 'space.import.sourcePlaceholder.gitlab': 'https://gitlab.com/group/project or group/sub/project',
114 'space.import.sourcePlaceholder.gitea': 'https://gitea.com/owner/repo or owner/repo',
115 'space.import.basePlaceholder.github': 'https://github.example.com (GitHub Enterprise instance)',
116 'space.import.basePlaceholder.gitlab': 'https://gitlab.example.com (self-hosted instance)',
117 'space.import.basePlaceholder.gitea': 'https://gitea.example.com (self-hosted instance)',
118 'space.import.field.source': 'Source',
119 'space.import.field.cloneUrl': 'Clone URL',
120 'space.import.field.defaultBranch': 'Default branch',
121 'space.import.field.visibility': 'Visibility',
122 'space.import.field.size': 'Size',
123 'space.import.field.description': 'Description',
124 'space.import.scopeNote': 'The import covers Git data and basic repository metadata only — no issues, pull requests, releases, wikis or LFS objects.',
125 'space.import.attempt': 'Attempt {n}',
126 'space.import.stat.branches': 'Branches',
127 'space.import.stat.tags': 'Tags',
128 'space.import.stat.commits': 'Commits',
129 'space.import.stat.size': 'Size',
130 'space.import.succeeded': 'Repository {repo} was imported. Default branch {branch}; open it from the list on the left.',
131 'space.import.retryToken': 'Retry token · Required for private repositories',
132 'space.import.retryTokenPlaceholder': 'The token is cleared when the job ends; provide a new one to retry',
133 'space.import.backToEdit': 'Back to edit',
134 'space.import.start': 'Start import',
135 'space.import.parsing': 'Probing…',
136 'space.import.parse': 'Probe source',
137 'space.import.cancelImport': 'Cancel import',
138 'space.import.retrying': 'Retrying…',
139 'space.import.stage.probe': 'Probing the source repository',
140 'space.import.stage.clone': 'Mirroring every ref',
141 'space.import.stage.sync': 'Syncing branches / commits / tags',
142 'space.import.stage.finalize': 'Counting and attributing authors',
143 'space.import.stage.done': 'Done',
144 'space.import.step.queued': 'Queued',
145 'space.import.step.processing': 'Running',
146 'space.import.step.succeeded': 'Completed',
147 'space.import.step.failed': 'Failed',
148 'space.import.step.canceled': 'Cancelled',
149 'space.import.actionFailed': 'The operation failed. Please try again later.',
15087 'space.menu.invite': 'Invite friends',
15188 'space.menu.createRepo': 'Create repository',
15289 'space.menu.createBoard': 'Create issue board',
Modified web/src/shared/i18n/messages/zh-CN/chat.ts View web/src/shared/i18n/messages/zh-CN/chat.ts
@@ -71,8 +71,6 @@
7171 'chat.voice.members': '在线成员',
7272 'chat.voice.empty': '频道内还没有其他人',
7373 'chat.voice.membersCount': '已有 {count} 人在频道里',
74 'chat.search.placeholder': '搜索消息',
75 'chat.search.empty': '没有匹配的消息',
7674 'chat.member.list': '成员',
7775 'chat.member.invite': '邀请成员',
7876 'chat.member.online': '在线',
@@ -237,12 +235,6 @@
237235 'chat.member.listFailed': '社区成员读取失败',
238236
239237 // Search panel.
240 'chat.search.aria': '搜索聊天消息',
241 'chat.search.inputPlaceholder': '关键词,输入即搜索',
242 'chat.search.onlyRoom': '仅本频道(取消则搜索整个社区)',
243 'chat.search.unsupported': '这里没有可检索的社区消息:私信与个人范围不支持全文检索。',
244 'chat.search.emptyFor': '没有匹配「{query}」的消息。',
245 'chat.search.hint': '输入关键词开始搜索',
246238
247239 // Attachments.
248240 'chat.attachment.unknownType': '未知类型',
Modified web/src/shared/i18n/messages/zh-CN/issues.ts View web/src/shared/i18n/messages/zh-CN/issues.ts
@@ -465,7 +465,6 @@
465465 'pull.checks.empty': '没有检查数据。',
466466 'pull.checks.versionEmpty': '该版本({sha})没有流水线运行或提交状态。',
467467 'pull.checks.version': '版本',
468 'pull.checks.pipeline': '流水线 #{id}',
469468 'pull.checks.details': '详情',
470469 'pull.commits.loading': '加载提交…',
471470 'pull.commits.errorList': '提交列表',
Modified web/src/shared/i18n/messages/zh-CN/misc.ts View web/src/shared/i18n/messages/zh-CN/misc.ts
@@ -10,16 +10,6 @@
1010 'notifications.filter.mentions': '提及',
1111 'notifications.delete': '删除通知',
1212 'notifications.deleted': '通知已删除。',
13 'search.requestFailed': '搜索失败',
14 'search.title': '搜索',
15 'search.placeholder': '搜索代码、工单、消息…',
16 'search.results': '搜索结果',
17 'search.empty': '没有找到匹配结果',
18 'search.failed': '搜索失败,请稍后重试。',
19 'search.tab.code': '代码',
20 'search.tab.issues': '工单',
21 'search.tab.messages': '消息',
22 'search.tab.repos': '仓库',
2313 'notifications.sub': '邀请、指派与系统提醒留在这里;提及和回复直接回到所在的频道与工单。',
2414 'notifications.filter': '通知筛选',
2515 'notifications.emptyUnread': '没有未读通知。',
@@ -46,22 +36,4 @@
4636 'notifications.bellUnread': '通知,{count} 条未读',
4737 'notifications.region': '系统通知',
4838 'notifications.closeToast': '关闭通知',
49 'search.command.placeholder': '搜索代码、符号、工单、拉取请求与消息…',
50 'search.command.open': '打开全局搜索',
51 'search.command.dialog': '全局搜索',
52 'search.command.close': '关闭搜索',
53 'search.command.types': '搜索类型',
54 'search.command.hint': '输入关键词开始搜索',
55 'search.command.needText': '请再输入关键词,限定条件已生效',
56 'search.command.filters': '当前搜索条件',
57 'search.command.scope': '当前范围',
58 'search.command.scopeFrom': '当前页面:',
59 'search.command.removeScope': '在全局范围内搜索(移除 {scope})',
60 'search.command.removeFilter': '移除条件 {filter}',
61 'search.command.loading': '正在搜索…',
62 'search.kind.code': '代码',
63 'search.kind.symbol': '符号',
64 'search.kind.issue': '工单',
65 'search.kind.pull': '拉取请求',
66 'search.kind.message': '消息',
6739} as const
Modified web/src/shared/i18n/messages/zh-CN/repo.ts View web/src/shared/i18n/messages/zh-CN/repo.ts
@@ -1,15 +1,14 @@
1/** Repository surfaces: browse, history, branches, releases, pipelines, settings. */
1/** Repository surfaces: browse, history, branches, releases, settings. */
22export const repo = {
33 'repo.tabs.code': '代码',
44 'repo.tabs.commits': '提交',
55 'repo.tabs.branches': '分支',
66 'repo.tabs.tags': '标签',
77 'repo.tabs.releases': '发布',
8 'repo.tabs.pipelines': '流水线',
98 'repo.tabs.settings': '设置',
109 'repo.tabs.pulls': '拉取请求',
1110 'repo.unknownPath': '(未知路径)',
12 'repo.import.failed': '导入请求失败', 'repo.empty': '仓库中没有文件',
11 'repo.empty': '仓库中没有文件',
1312 'repo.loading': '正在加载仓库…',
1413 'repo.notFound': '仓库不存在或已删除。',
1514 'repo.branch.default': '默认分支',
@@ -63,16 +62,6 @@
6362 'repo.release.deleted': '发布已删除。',
6463 'repo.release.empty': '还没有发布',
6564 'repo.release.downloads': '下载量',
66 'repo.pipeline.run': '运行流水线',
67 'repo.pipeline.trigger': '手动触发',
68 'repo.pipeline.cancel': '取消运行',
69 'repo.pipeline.rerun': '重新运行',
70 'repo.pipeline.triggered': '流水线已触发。',
71 'repo.pipeline.cancelled': '流水线已取消。',
72 'repo.pipeline.empty': '还没有流水线运行记录',
73 'repo.pipeline.jobs': '作业',
74 'repo.pipeline.logs': '日志',
75 'repo.pipeline.artifacts': '产物',
7665 'repo.settings.general': '常规',
7766 'repo.settings.name': '仓库名称',
7867 'repo.settings.description': '仓库描述',
@@ -94,13 +83,6 @@
9483 'repo.lfs.enabled': 'LFS 已启用',
9584 'repo.lfs.patterns': '跟踪模式',
9685 'repo.lfs.objects': 'LFS 对象',
97 'repo.webhook.add': '新建 Webhook',
98 'repo.webhook.url': '回调地址',
99 'repo.webhook.secret': '签名密钥',
100 'repo.webhook.events': '触发事件',
101 'repo.webhook.created': 'Webhook 已创建。',
102 'repo.webhook.deleted': 'Webhook 已删除。',
103 'repo.webhook.empty': '还没有 Webhook',
10486 'repo.action.add': '添加',
10587 'repo.action.browseCode': '浏览代码',
10688 'repo.action.confirmDelete': '确认删除',
@@ -315,46 +297,6 @@
315297 'repo.release.tagAge': '({age})',
316298 'repo.release.titleOptional': '标题(可选)',
317299 'repo.release.saveAsDraft': '先保存为草稿',
318 'repo.pipeline.heading': '流水线',
319 'repo.pipeline.subtitle': '由 .github/workflows/ 中的 GitHub Actions 兼容工作流驱动,runner 通过调度中心领取作业。',
320 'repo.pipeline.state.pending': '等待依赖',
321 'repo.pipeline.state.claimed': '已领取',
322 'repo.pipeline.state.timedOut': '超时',
323 'repo.pipeline.state.cancelRequested': '取消中',
324 'repo.pipeline.state.ready': '就绪',
325 'repo.pipeline.state.uploading': '上传中',
326 'repo.pipeline.runsLoading': '正在加载运行记录…',
327 'repo.pipeline.runsEmpty': '还没有运行记录。推送包含 .github/workflows/*.yml 的提交,或在此手动触发。',
328 'repo.pipeline.triggerFailed': '触发失败',
329 'repo.pipeline.triggerTitle': '手动触发 workflow_dispatch',
330 'repo.pipeline.ref': '分支或标签',
331 'repo.pipeline.workflow': '工作流',
332 'repo.pipeline.workflowsFailed': '工作流加载失败',
333 'repo.pipeline.noDispatchable': '该修订下没有可手动触发的工作流(需在 on.workflow_dispatch 中声明)。',
334 'repo.pipeline.triggering': '触发中…',
335 'repo.pipeline.triggerConfirm': '触发',
336 'repo.pipeline.detailLoading': '正在加载运行详情…',
337 'repo.pipeline.attempt': '第 {count} 次尝试',
338 'repo.pipeline.noLogs': '(暂无日志输出)',
339 'repo.pipeline.runNumber': '#{number}',
340 'repo.pipeline.matrix': 'Matrix',
341 'repo.pipeline.outputs': 'Outputs',
342 'repo.pipeline.annotations': 'Annotations',
343 'repo.pipeline.summary': 'Summary',
344 'repo.pipeline.emptyOutputs': '暂无 outputs',
345 'repo.pipeline.emptyAnnotations': '暂无 annotations',
346 'repo.pipeline.inputs': '输入参数',
347 'repo.pipeline.inputRequired': '必填',
348 'repo.pipeline.approvals': '环境审批',
349 'repo.pipeline.noApprovals': '当前运行没有环境审批记录',
350 'repo.pipeline.approvalPending': '等待审批',
351 'repo.pipeline.approvalApproved': '已批准',
352 'repo.pipeline.approvalRejected': '已拒绝',
353 'repo.pipeline.approve': '批准',
354 'repo.pipeline.reject': '拒绝',
355 'repo.pipeline.approvalFailed': '审批操作失败',
356 'repo.pipeline.booleanTrue': '是',
357 'repo.pipeline.booleanFalse': '否',
358300 'repo.comment.unknownUser': '未知用户',
359301 'repo.comment.resolved': '已解决',
360302 'repo.comment.outdated': '已过期',
@@ -394,8 +336,6 @@
394336 'repo.featuresSettings.sub': '控制仓库标签条里有哪些页签、以及它们的顺序。仅空间所有者可修改。',
395337 'repo.featuresSettings.allowPr': '允许合并请求',
396338 'repo.featuresSettings.allowPrHint': '关闭后「合并请求」页签对所有人隐藏,创建合并请求会被拒绝。',
397 'repo.featuresSettings.pipelinesPublic': '公开展示流水线',
398 'repo.featuresSettings.pipelinesPublicHint': '关闭后「流水线」页签仅空间成员可见。',
399339 'repo.featuresSettings.orderTitle': '页签顺序',
400340 'repo.featuresSettings.orderHint': '调整「提交 / 合并请求 / 分支 / 流水线 / 发布」的顺序;代码与设置固定不动。',
401341 'repo.featuresSettings.moveUp': '上移 {name}',
@@ -573,44 +513,6 @@
573513 'repo.archives.factSize': '大小',
574514 'repo.archives.openDownload': '在新标签页下载',
575515 'repo.archives.linkNote': '链接为短期有效,失效后点「复用已有归档」重新获取。',
576 'repo.ci.secretSaved': 'secret 已保存',
577 'repo.ci.secretDeleted': 'secret 已删除',
578 'repo.ci.deleteFailed': '删除失败',
579 'repo.ci.sub': '工作流定义来自默认分支(或当前修订)的 .github/workflows/ 目录;runner 通过调度中心领取作业。',
580 'repo.ci.workflows': '工作流',
581 'repo.ci.workflowsLoading': '正在读取 .github/workflows/…',
582 'repo.ci.workflowsEmpty': '该修订下没有 .github/workflows/*.yml 定义。',
583 'repo.ci.valid': '有效',
584 'repo.ci.invalid': '解析失败',
585 'repo.ci.dispatchable': '手动触发可用',
586 'repo.ci.secretsTitle': '仓库 secrets',
587 'repo.ci.secretsFailed': 'secret 加载失败',
588 'repo.ci.updatedAt': '更新于 {time}',
589 'repo.ci.secretsEmpty': '尚未配置 secret。',
590 'repo.ci.upsertSecret': '新增 / 覆盖 secret',
591 'repo.ci.upsertSecretHint': '值只会写库(AES-GCM 加密),接口不会回显。',
592 'repo.ci.secretNamePlaceholder': '名称(字母、数字、_、-)',
593 'repo.ci.value': '值',
594 'repo.ci.secretValuePlaceholder': 'secret 值',
595 'repo.ci.saveSecret': '保存 secret',
596 'repo.import.state.processing': '进行中',
597 'repo.import.state.succeeded': '已完成',
598 'repo.import.stage.probe': '解析源仓库',
599 'repo.import.stage.clone': '镜像抓取引用',
600 'repo.import.stage.sync': '同步分支与提交',
601 'repo.import.stage.finalize': '统计与作者归属',
602 'repo.import.stage.done': '完成',
603 'repo.import.sub': '从 GitHub / GitLab / Gitea 镜像导入本仓库的任务记录。新导入请在社区菜单的「导入仓库」中发起。',
604 'repo.import.empty': '本仓库没有导入任务。',
605 'repo.import.attempts': '· 已尝试 {count} 次',
606 'repo.import.stats': '分支 {branches} · 标签 {tags} · 提交 {commits} · 体积 {size}',
607 'repo.import.statsDefault': '· 默认分支 {branch}',
608 'repo.import.canceled': '已取消导入任务。',
609 'repo.import.confirmCancel': '确认取消',
610 'repo.import.requeued': '已重新排队导入任务。',
611 'repo.import.token': '重试令牌',
612 'repo.import.tokenHint': '任务结束后服务端已清除令牌;私有仓库重试时需要重新提供。',
613 'repo.import.tokenPlaceholder': '留空表示匿名重试',
614516 'repo.remotes.sub': '仓库配置的 git remote。地址必须是 https:// / ssh:// / git://,且不能指向 localhost 或内网地址。',
615517 'repo.remotes.loading': '加载远程…',
616518 'repo.remotes.empty': '尚未配置远程。',
Modified web/src/shared/i18n/messages/zh-CN/shell.ts View web/src/shared/i18n/messages/zh-CN/shell.ts
@@ -47,7 +47,6 @@
4747 'shell.repo.tabs.commits': '提交',
4848 'shell.repo.tabs.pulls': '合并请求',
4949 'shell.repo.tabs.branches': '分支',
50 'shell.repo.tabs.pipelines': '流水线',
5150 'shell.repo.tabs.releases': '发布',
5251 'shell.repo.tabs.settings': '设置',
5352 'shell.repo.tabs.more': '更多',
Modified web/src/shared/i18n/messages/zh-CN/space.ts View web/src/shared/i18n/messages/zh-CN/space.ts
@@ -1,15 +1,9 @@
1/** Space management: creation, import, members, roles, invites, settings. */
1/** Space management: creation, members, roles, invites, settings. */
22export const space = {
33 'space.create': '新建空间',
44 'space.create.name': '空间名称',
55 'space.create.slug': 'URL 标识',
66 'space.create.description': '空间描述',
7 'space.import': '导入仓库',
8 'space.import.url': '上游仓库地址',
9 'space.import.name': '仓库名称',
10 'space.import.started': '导入任务已创建。',
11 'space.import.disabled': '仓库导入功能当前已关闭。',
12 'space.import.upstreamFailed': '无法访问上游仓库,请检查地址或凭据。',
137 'space.settings.title': '空间设置',
148 'space.settings.general': '常规',
159 'space.settings.members': '成员',
@@ -90,63 +84,6 @@
9084 'space.create.descriptionPlaceholder': '这个空间用来做什么?',
9185 'space.create.private': '私有空间',
9286 'space.create.creating': '创建中…',
93 'space.import.title': '导入外部仓库',
94 'space.import.progress': '导入进度 · {repo}',
95 'space.import.subtitleForm': '从 GitHub / GitLab / Gitea 镜像导入一个仓库:全部分支、标签与提交历史,以及描述、默认分支和私有性。',
96 'space.import.subtitleConfirm': '确认解析结果后创建仓库并开始异步镜像。',
97 'space.import.subtitleProgress': '任务由 synchost 异步执行,可以关闭本窗口稍后再来查看。',
98 'space.import.provider': '来源平台',
99 'space.import.source': '仓库地址或 owner/repo',
100 'space.import.base': '实例地址 · 自建时填写',
101 'space.import.baseHint': '内网或 localhost 实例需要管理员先把它加入 {field}。',
102 'space.import.token': '访问令牌 · 私有仓库必填',
103 'space.import.tokenPlaceholder': '留空表示匿名导入公开仓库',
104 'space.import.localName': '本地仓库名 · 可选',
105 'space.import.localNamePlaceholder': '留空沿用源仓库名',
106 'space.import.dir': '所在文件夹',
107 'space.import.root': '根目录',
108 'space.import.description': '描述 · 可选',
109 'space.import.descriptionPlaceholder': '留空沿用源仓库描述',
110 'space.import.private': '私有仓库(留空则沿用源仓库可见性)',
111 'space.import.enableLfs': '启用 Git LFS',
112 'space.import.sourcePlaceholder.github': 'https://github.com/octocat/hello-world 或 octocat/hello-world',
113 'space.import.sourcePlaceholder.gitlab': 'https://gitlab.com/group/project 或 group/sub/project',
114 'space.import.sourcePlaceholder.gitea': 'https://gitea.com/owner/repo 或 owner/repo',
115 'space.import.basePlaceholder.github': 'https://github.example.com(GitHub Enterprise 实例地址)',
116 'space.import.basePlaceholder.gitlab': 'https://gitlab.example.com(自建实例地址)',
117 'space.import.basePlaceholder.gitea': 'https://gitea.example.com(自建实例地址)',
118 'space.import.field.source': '来源',
119 'space.import.field.cloneUrl': '克隆地址',
120 'space.import.field.defaultBranch': '默认分支',
121 'space.import.field.visibility': '可见性',
122 'space.import.field.size': '体积',
123 'space.import.field.description': '描述',
124 'space.import.scopeNote': '导入只覆盖 Git 数据与仓库基本元数据,不包含 Issue、Pull Request、Release、Wiki 与 LFS 大对象。',
125 'space.import.attempt': '第 {n} 次尝试',
126 'space.import.stat.branches': '分支',
127 'space.import.stat.tags': '标签',
128 'space.import.stat.commits': '提交',
129 'space.import.stat.size': '体积',
130 'space.import.succeeded': '仓库 {repo} 已导入完成,默认分支 {branch},可在左侧列表中打开。',
131 'space.import.retryToken': '重试令牌 · 私有仓库必填',
132 'space.import.retryTokenPlaceholder': '任务结束后令牌已清除,重试需重新提供',
133 'space.import.backToEdit': '返回修改',
134 'space.import.start': '开始导入',
135 'space.import.parsing': '解析中…',
136 'space.import.parse': '解析源仓库',
137 'space.import.cancelImport': '取消导入',
138 'space.import.retrying': '重试中…',
139 'space.import.stage.probe': '解析源仓库',
140 'space.import.stage.clone': '镜像抓取全部引用',
141 'space.import.stage.sync': '同步分支 / 提交 / 标签',
142 'space.import.stage.finalize': '统计与作者归属',
143 'space.import.stage.done': '完成',
144 'space.import.step.queued': '排队中',
145 'space.import.step.processing': '进行中',
146 'space.import.step.succeeded': '已完成',
147 'space.import.step.failed': '失败',
148 'space.import.step.canceled': '已取消',
149 'space.import.actionFailed': '操作失败,请稍后重试。',
15087 'space.menu.invite': '邀请朋友',
15188 'space.menu.createRepo': '创建仓库',
15289 'space.menu.createBoard': '创建问题集',
Modified web/openapi.json View web/openapi.json

Diff too large to display.

Modified web/steiger.config.js View web/steiger.config.js
@@ -19,7 +19,6 @@
1919 'features/repo-branches',
2020 'features/repo-browse',
2121 'features/repo-history',
22 'features/repo-pipelines',
2322 'features/repo-settings',
2423 'features/repo-releases',
2524 'features/search',
Modified Cargo.lock View Cargo.lock
@@ -985,7 +985,6 @@
985985 "redis",
986986 "reqwest 0.12.28",
987987 "sea-orm",
988 "search",
989988 "serde",
990989 "serde_json",
991990 "sha2 0.11.0",
@@ -1016,54 +1015,6 @@
10161015]
10171016
10181017[[package]]
1019name = "ci"
1020version = "1.0.0"
1021dependencies = [
1022 "aes-gcm 0.10.3",
1023 "anyhow",
1024 "async-trait",
1025 "axum",
1026 "base64 0.23.1",
1027 "ci-protocol",
1028 "config",
1029 "export",
1030 "futures",
1031 "git",
1032 "globset",
1033 "hmac 0.13.0",
1034 "jwt",
1035 "models",
1036 "rand 0.8.7",
1037 "reqwest 0.12.28",
1038 "runner",
1039 "sea-orm",
1040 "serde",
1041 "serde_json",
1042 "serde_yaml_ng",
1043 "sha2 0.11.0",
1044 "space",
1045 "storage",
1046 "thiserror 2.0.20",
1047 "time 0.3.55",
1048 "tokio",
1049 "tracing",
1050 "utoipa",
1051 "uuid",
1052]
1053
1054[[package]]
1055name = "ci-protocol"
1056version = "1.0.0"
1057dependencies = [
1058 "globset",
1059 "serde",
1060 "serde_json",
1061 "time 0.3.55",
1062 "utoipa",
1063 "uuid",
1064]
1065
1066[[package]]
10671018name = "cipher"
10681019version = "0.4.4"
10691020source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1164,7 +1115,6 @@
11641115 "base64 0.23.1",
11651116 "dotenvy",
11661117 "figment",
1167 "ipnet",
11681118 "sea-orm",
11691119 "serde",
11701120 "thiserror 2.0.20",
@@ -2067,7 +2017,6 @@
20672017 "base64 0.23.1",
20682018 "bytes",
20692019 "cache",
2070 "ci-protocol",
20712020 "config",
20722021 "export",
20732022 "flate2",
@@ -2081,10 +2030,8 @@
20812030 "models",
20822031 "prost-types 0.14.1",
20832032 "pulldown-cmark",
2084 "rand 0.8.7",
20852033 "redis",
20862034 "sea-orm",
2087 "search",
20882035 "serde",
20892036 "serde_json",
20902037 "sha2 0.11.0",
@@ -2633,7 +2580,6 @@
26332580name = "i18n"
26342581version = "1.0.0"
26352582dependencies = [
2636 "ci-protocol",
26372583 "models",
26382584 "sea-orm",
26392585 "uuid",
@@ -2850,33 +2796,6 @@
28502796]
28512797
28522798[[package]]
2853name = "import"
2854version = "1.0.0"
2855dependencies = [
2856 "async-trait",
2857 "auth",
2858 "axum",
2859 "cache",
2860 "config",
2861 "export",
2862 "git",
2863 "jwt",
2864 "kenshell",
2865 "models",
2866 "reqwest 0.12.28",
2867 "sea-orm",
2868 "serde",
2869 "serde_json",
2870 "thiserror 2.0.20",
2871 "time 0.3.55",
2872 "tokio",
2873 "tokio-util",
2874 "tracing",
2875 "utoipa",
2876 "uuid",
2877]
2878
2879[[package]]
28802799name = "indexmap"
28812800version = "2.14.0"
28822801source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2941,7 +2860,6 @@
29412860 "kenauth",
29422861 "models",
29432862 "sea-orm",
2944 "search",
29452863 "serde",
29462864 "serde_json",
29472865 "space",
@@ -4785,8 +4703,6 @@
47854703version = "1.0.0"
47864704dependencies = [
47874705 "axum",
4788 "ci",
4789 "ci-protocol",
47904706 "config",
47914707 "export",
47924708 "git",
@@ -4795,7 +4711,6 @@
47954711 "jwt",
47964712 "models",
47974713 "sea-orm",
4798 "search",
47994714 "serde",
48004715 "serde_json",
48014716 "space",
@@ -5265,29 +5180,6 @@
52655180]
52665181
52675182[[package]]
5268name = "runner"
5269version = "1.0.0"
5270dependencies = [
5271 "anyhow",
5272 "base64 0.23.1",
5273 "ci-protocol",
5274 "config",
5275 "dotenvy",
5276 "export",
5277 "figment",
5278 "globset",
5279 "reqwest 0.12.28",
5280 "serde",
5281 "serde_json",
5282 "sha2 0.11.0",
5283 "time 0.3.55",
5284 "tokio",
5285 "tracing",
5286 "tracing-subscriber",
5287 "uuid",
5288]
5289
5290[[package]]
52915183name = "rust-embed"
52925184version = "8.12.0"
52935185source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5696,48 +5588,6 @@
56965588checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
56975589
56985590[[package]]
5699name = "search"
5700version = "1.0.0"
5701dependencies = [
5702 "config",
5703 "export",
5704 "models",
5705 "reqwest 0.12.28",
5706 "sea-orm",
5707 "serde",
5708 "serde_json",
5709 "thiserror 2.0.20",
5710 "time 0.3.55",
5711 "tokio",
5712 "tokio-util",
5713 "tracing",
5714 "tree-sitter",
5715 "tree-sitter-c",
5716 "tree-sitter-cpp",
5717 "tree-sitter-go",
5718 "tree-sitter-java",
5719 "tree-sitter-javascript",
5720 "tree-sitter-python",
5721 "tree-sitter-rust",
5722 "tree-sitter-typescript",
5723 "utoipa",
5724 "uuid",
5725]
5726
5727[[package]]
5728name = "searchhost"
5729version = "1.0.0"
5730dependencies = [
5731 "anyhow",
5732 "config",
5733 "export",
5734 "search",
5735 "tokio",
5736 "tokio-util",
5737 "tracing",
5738]
5739
5740[[package]]
57415591name = "sec1"
57425592version = "0.7.3"
57435593source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5816,7 +5666,6 @@
58165666source = "registry+https://github.com/rust-lang/crates.io-index"
58175667checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
58185668dependencies = [
5819 "indexmap",
58205669 "itoa",
58215670 "memchr",
58225671 "serde",
@@ -5857,19 +5706,6 @@
58575706]
58585707
58595708[[package]]
5860name = "serde_yaml_ng"
5861version = "0.10.0"
5862source = "registry+https://github.com/rust-lang/crates.io-index"
5863checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f"
5864dependencies = [
5865 "indexmap",
5866 "itoa",
5867 "ryu",
5868 "serde",
5869 "unsafe-libyaml",
5870]
5871
5872[[package]]
58735709name = "sha1"
58745710version = "0.10.7"
58755711source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6087,9 +5923,7 @@
60875923 "kenauth",
60885924 "models",
60895925 "prost-types 0.14.1",
6090 "rand 0.8.7",
60915926 "sea-orm",
6092 "search",
60935927 "serde",
60945928 "serde_json",
60955929 "storage",
@@ -6347,12 +6181,6 @@
63476181]
63486182
63496183[[package]]
6350name = "streaming-iterator"
6351version = "0.1.9"
6352source = "registry+https://github.com/rust-lang/crates.io-index"
6353checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
6354
6355[[package]]
63566184name = "stringprep"
63576185version = "0.1.5"
63586186source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6448,25 +6276,6 @@
64486276]
64496277
64506278[[package]]
6451name = "synchost"
6452version = "1.0.0"
6453dependencies = [
6454 "anyhow",
6455 "axum",
6456 "cache",
6457 "config",
6458 "export",
6459 "git",
6460 "import",
6461 "kenshell",
6462 "search",
6463 "tokio",
6464 "tokio-util",
6465 "tracing",
6466 "tracing-subscriber",
6467]
6468
6469[[package]]
64706279name = "synstructure"
64716280version = "0.13.2"
64726281source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7018,106 +6827,6 @@
70186827checksum = "04a79e25382e2e852e8da874249358d382ebaf259d0d34e75d8db16a7efabbc7"
70196828
70206829[[package]]
7021name = "tree-sitter"
7022version = "0.25.10"
7023source = "registry+https://github.com/rust-lang/crates.io-index"
7024checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87"
7025dependencies = [
7026 "cc",
7027 "regex",
7028 "regex-syntax",
7029 "serde_json",
7030 "streaming-iterator",
7031 "tree-sitter-language",
7032]
7033
7034[[package]]
7035name = "tree-sitter-c"
7036version = "0.24.2"
7037source = "registry+https://github.com/rust-lang/crates.io-index"
7038checksum = "a9b2eb57a55fed6b00812912e730b7a275cf4fe98bfd6a5d76263d4438371728"
7039dependencies = [
7040 "cc",
7041 "tree-sitter-language",
7042]
7043
7044[[package]]
7045name = "tree-sitter-cpp"
7046version = "0.23.4"
7047source = "registry+https://github.com/rust-lang/crates.io-index"
7048checksum = "df2196ea9d47b4ab4a31b9297eaa5a5d19a0b121dceb9f118f6790ad0ab94743"
7049dependencies = [
7050 "cc",
7051 "tree-sitter-language",
7052]
7053
7054[[package]]
7055name = "tree-sitter-go"
7056version = "0.25.0"
7057source = "registry+https://github.com/rust-lang/crates.io-index"
7058checksum = "c8560a4d2f835cc0d4d2c2e03cbd0dde2f6114b43bc491164238d333e28b16ea"
7059dependencies = [
7060 "cc",
7061 "tree-sitter-language",
7062]
7063
7064[[package]]
7065name = "tree-sitter-java"
7066version = "0.23.5"
7067source = "registry+https://github.com/rust-lang/crates.io-index"
7068checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6"
7069dependencies = [
7070 "cc",
7071 "tree-sitter-language",
7072]
7073
7074[[package]]
7075name = "tree-sitter-javascript"
7076version = "0.25.0"
7077source = "registry+https://github.com/rust-lang/crates.io-index"
7078checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5"
7079dependencies = [
7080 "cc",
7081 "tree-sitter-language",
7082]
7083
7084[[package]]
7085name = "tree-sitter-language"
7086version = "0.1.8"
7087source = "registry+https://github.com/rust-lang/crates.io-index"
7088checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081"
7089
7090[[package]]
7091name = "tree-sitter-python"
7092version = "0.25.0"
7093source = "registry+https://github.com/rust-lang/crates.io-index"
7094checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c"
7095dependencies = [
7096 "cc",
7097 "tree-sitter-language",
7098]
7099
7100[[package]]
7101name = "tree-sitter-rust"
7102version = "0.24.2"
7103source = "registry+https://github.com/rust-lang/crates.io-index"
7104checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45"
7105dependencies = [
7106 "cc",
7107 "tree-sitter-language",
7108]
7109
7110[[package]]
7111name = "tree-sitter-typescript"
7112version = "0.23.2"
7113source = "registry+https://github.com/rust-lang/crates.io-index"
7114checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff"
7115dependencies = [
7116 "cc",
7117 "tree-sitter-language",
7118]
7119
7120[[package]]
71216830name = "try-lock"
71226831version = "0.2.5"
71236832source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7232,12 +6941,6 @@
72326941]
72336942
72346943[[package]]
7235name = "unsafe-libyaml"
7236version = "0.2.11"
7237source = "registry+https://github.com/rust-lang/crates.io-index"
7238checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
7239
7240[[package]]
72416944name = "untrusted"
72426945version = "0.9.0"
72436946source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7579,17 +7282,14 @@
75797282 "auth",
75807283 "axum",
75817284 "chat",
7582 "ci",
75837285 "config",
75847286 "export",
75857287 "git",
7586 "import",
75877288 "issues",
75887289 "jwt",
75897290 "models",
75907291 "pulls",
75917292 "sea-orm",
7592 "search",
75937293 "serde",
75947294 "serde_json",
75957295 "space",
@@ -7601,31 +7301,6 @@
76017301 "utoipa",
76027302 "uuid",
76037303 "web",
7604]
7605
7606[[package]]
7607name = "webhook"
7608version = "1.0.0"
7609dependencies = [
7610 "anyhow",
7611 "axum",
7612 "ci",
7613 "ci-protocol",
7614 "config",
7615 "export",
7616 "git",
7617 "models",
7618 "reqwest 0.12.28",
7619 "sea-orm",
7620 "serde",
7621 "serde_json",
7622 "storage",
7623 "time 0.3.55",
7624 "tokio",
7625 "tokio-util",
7626 "tracing",
7627 "tracing-subscriber",
7628 "uuid",
76297304]
76307305
76317306[[package]]
Modified Cargo.toml View Cargo.toml
@@ -17,8 +17,6 @@
1717auth = { path = "lib/auth" }
1818cache = { path = "lib/cache" }
1919chat = { path = "lib/chat" }
20ci-protocol = { path = "lib/ci-protocol" }
21ci = { path = "lib/ci" }
2220config = { path = "lib/config" }
2321export = { path = "lib/export" }
2422models = { path = "lib/models" }
@@ -33,10 +31,8 @@
3331kenauth = { path = "lib/kenauth" }
3432kenshell = { path = "lib/kenshell" }
3533jwt = { path = "lib/jwt" }
36import = { path = "lib/import" }
3734webapi = { path = "lib/webapi" }
3835pulls = { path = "lib/pulls" }
39search = { path = "lib/search" }
4036social = { path = "lib/social" }
4137web = { path = "web" }
4238
@@ -95,23 +91,11 @@
9591hyper = { version = "1", features = ["server", "http1"] }
9692hyper-util = { version = "0.1", features = ["tokio"] }
9793tokio-tungstenite = "0.29"
98tree-sitter = "0.25"
99tree-sitter-rust = "0.24.2"
100tree-sitter-javascript = "0.25.0"
101tree-sitter-typescript = "0.23.2"
102tree-sitter-python = "0.25.0"
103tree-sitter-go = "0.25.0"
104tree-sitter-java = "0.23.5"
105tree-sitter-c = "0.24.2"
106tree-sitter-cpp = "0.23.4"
107
10894
109serde_yaml_ng = "0.10"
11095regex = "1"
11196url = "2"
11297percent-encoding = "2"
11398encoding_rs = "0.8"
114ipnet = "2"
11599tonic = { version = "0.14", default-features = false, features = ["channel", "codegen", "router", "server"] }
116100tonic-prost = "0.14"
117101prost = "0.14"
Modified docker-compose.dev.yml View docker-compose.dev.yml
@@ -1,6 +1,6 @@
11# 本地开发依赖服务(macOS / Linux)。
22# 与 config/local.toml 的默认端口一一对应:
3# PostgreSQL 5432 / Redis 6379 / S3(RustFS) 9000 / Meilisearch 7700
3# PostgreSQL 5432 / Redis 6379 / S3(RustFS) 9000
44# 用法:
55# just infra 启动
66# just infra-down 停止
@@ -62,24 +62,6 @@
6262 timeout: 3s
6363 retries: 20
6464
65 meilisearch:
66 image: getmeili/meilisearch:v1.13
67 container_name: gitdata-dev-meilisearch
68 restart: unless-stopped
69 environment:
70 MEILI_MASTER_KEY: 48e6813b44ed0de5d7272e06967703fb
71 MEILI_ENV: development
72 MEILI_NO_ANALYTICS: "true"
73 ports:
74 - "127.0.0.1:7700:7700"
75 volumes:
76 - meili-data:/meili_data
77 healthcheck:
78 test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:7700/health >/dev/null || exit 1"]
79 interval: 5s
80 timeout: 3s
81 retries: 20
82
8365 # NATS:KenOAuth 的事件与缓存失效通道(本地开发必需)
8466 nats:
8567 image: nats:2-alpine
@@ -130,4 +112,3 @@
130112volumes:
131113 postgres-data:
132114 rustfs-data:
133 meili-data:
Modified Dockerfile View Dockerfile
@@ -1,10 +1,5 @@
11# syntax=docker/dockerfile:1
22
3# The actions engine (gitdata-actions-engine) lives in its own repository now,
4# `git-data-actions`. Pin the tag on release — the runner pool pulls the very same
5# image, so an unset/`latest` value is only a convenience for local builds.
6ARG ACTIONS_IMAGE=harbor.gitdata.me/app/gitdata-act:latest
7
83# Bun bundles the web SPA from inside web/build.rs. Take the binary from the
94# official image instead of running the network installer: the installer pulls
105# its archive from GitHub releases, which regularly times out here.
@@ -43,54 +38,31 @@
4338 /build/target/release/deps/web-* 2>/dev/null; \
4439 cargo build --release --workspace --bins && \
4540 mkdir -p /out && \
46 for b in apihost githost synchost searchhost migrate openapi im webhook runner; do \
41 for b in apihost migrate openapi im; do \
4742 cp "target/release/$b" /out/; \
4843 done
4944
50# The runner shells out to `docker run` for `container:` jobs; ship just the
51# static CLI so the image stays daemon-free.
52RUN curl -fsSL "https://mirrors.aliyun.com/docker-ce/linux/static/stable/x86_64/docker-27.3.1.tgz" -o /tmp/docker.tgz \
53 && tar -xzf /tmp/docker.tgz -C /tmp \
54 && cp /tmp/docker/docker /out/docker \
55 && rm -rf /tmp/docker /tmp/docker.tgz
56
57########## actions engine ##########
58# The server side of CI (workflow parse/trigger/evaluate/expand, cron matching,
59# OIDC) shells out to `gitdata-actions-engine` on every operation, not just the
60# runner; ship it in this image too. The binary is lifted out of the published
61# actions image instead of built here, so the server and the runner pool always
62# run the very same engine build.
63FROM ${ACTIONS_IMAGE} AS actions-engine
64
6545########## runtime ##########
6646FROM debian:bookworm-slim
6747
68# git: githost/synchost spawn system git; openssh-client: ssh-keygen for host key
48# ca-certificates: outbound TLS; git: available for manual maintenance
6949RUN apt-get update && apt-get install -y --no-install-recommends \
7050 git \
7151 ca-certificates \
7252 libssl3 \
73 openssh-client \
7453 && rm -rf /var/lib/apt/lists/* \
7554 && useradd --system --create-home --uid 10001 gitdata \
76 && mkdir -p /opt/gitdata/repos /opt/gitdata/objects /var/lib/gitdata-runner \
77 && chown -R gitdata:gitdata /opt/gitdata /var/lib/gitdata-runner
55 && mkdir -p /opt/gitdata/repos /opt/gitdata/objects \
56 && chown -R gitdata:gitdata /opt/gitdata
7857
7958WORKDIR /app
8059
8160COPY --chown=gitdata:gitdata config/default.toml config/default.toml
82COPY --from=builder /out/apihost /out/githost /out/synchost /out/searchhost /out/migrate /out/openapi /out/im /out/webhook /out/runner /usr/local/bin/
83
84COPY --from=builder /out/docker /usr/local/bin/docker
85
86# `gitdata-actions-engine` is spawned by the CI service (lib/ci/src/actions.rs:145)
87# as well as by the runner, so the default APP_CI__ACTIONS_ENGINE_BINARY value
88# (a bare name resolved via PATH) works in every role.
89COPY --from=actions-engine /usr/local/bin/gitdata-actions-engine /usr/local/bin/gitdata-actions-engine
61COPY --from=builder /out/apihost /out/migrate /out/openapi /out/im /usr/local/bin/
9062
9163USER gitdata
9264
93EXPOSE 3000 9418 22 3001 3002 3003 3005 3006 3007
65EXPOSE 3000 3001
9466
9567CMD ["apihost"]
9668
Modified justfile View justfile
@@ -1,7 +1,7 @@
11# GitData 本地开发入口(Windows / macOS / Linux)。
22#
3# Windows:直接在 Windows 上跑 Rust/前端,依赖服务(PostgreSQL / Redis / RustFS /
4# Meilisearch)由 WSL(Ubuntu) 的 systemd 托管(just infra)。
3# Windows:直接在 Windows 上跑 Rust/前端,依赖服务(PostgreSQL / Redis / RustFS)
4# 由 WSL(Ubuntu) 的 systemd 托管(just infra)。
55# macOS/Linux:原生跑 Rust/前端,依赖服务由 docker compose 托管(just infra)。
66#
77# 配置沿用 config/local.toml(gitignore)。首次使用:
@@ -36,7 +36,7 @@
3636check:
3737 @{{ invoke }}"{{ dev }}" check
3838
39# 启动依赖服务:PostgreSQL / Redis / RustFS(S3) / Meilisearch
39# 启动依赖服务:PostgreSQL / Redis / RustFS(S3)
4040infra:
4141 @{{ invoke }}"{{ dev }}" infra
4242
@@ -62,7 +62,7 @@
6262up *svc='':
6363 @{{ invoke }}"{{ dev }}" up {{ svc }}
6464
65# 仅启动后端服务(apihost/im/synchost/webhook)
65# 仅启动后端服务(apihost/im)
6666up-backend:
6767 @{{ invoke }}"{{ dev }}" up backends
6868
@@ -98,8 +98,6 @@
9898urls:
9999 @echo " apihost http://127.0.0.1:4081 (Swagger: /api/docs)"
100100 @echo " im ws://127.0.0.1:4082"
101 @echo " synchost http://127.0.0.1:4083/healthz"
102 @echo " webhook http://127.0.0.1:3005"
103101 @echo " web http://127.0.0.1:5173"
104102
105103# 重新生成 openapi.json
Modified openapi.json View openapi.json

Diff too large to display.