Skip to content
Clone

merge: remove search/sync/webhook/CI and add RocketMQ outbox relay

ZhenYi committed
parents 5f7b107 + 27fffe3 commit 631aa98 Browse files

300 files changed

+1400 −15865

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

File tree 300
Modified app/apihost/src/main.rs View app/apihost/src/main.rs
@@ -1,3 +1,5 @@
1#![recursion_limit = "256"]
2
13use std::net::SocketAddr;
24
35use anyhow::Context;
@@ -38,6 +40,20 @@
3840
3941 tokio::spawn(chat::worker::run_background_tasks(state.clone()));
4042
43 if cfg.rocketmq.enabled {
44 let conn = queue::RocketMqConn::init(&cfg.rocketmq)
45 .await
46 .map_err(|err| anyhow::anyhow!("failed to start the rocketmq producer: {err}"))?;
47 tokio::spawn(queue::relay::run(
48 state.database.primary().clone(),
49 queue::Publisher::new(conn),
50 CancellationToken::new(),
51 ));
52 info!("event outbox relay started");
53 } else {
54 info!("rocketmq is disabled; chat message events are not published");
55 }
56
4157 // Web Push 消费者:与其它后台任务一起挂在 apihost 上,不为它单开进程。
4258 // 配置非法(密钥缺失或公私钥不成对)时直接报错退出——静默不投递比启动
4359 // 失败更难被发现。
Modified app/apihost/Cargo.toml View app/apihost/Cargo.toml
@@ -9,6 +9,7 @@
99auth = { workspace = true }
1010chat = { workspace = true }
1111push = { workspace = true }
12queue = { workspace = true }
1213export = { workspace = true }
1314jwt = { workspace = true }
1415config = { workspace = true }
Deleted app/im/src/frames.rs
@@ -1,62 +0,0 @@
1use chat::services::messages::MessageDto;
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Deserialize)]
6#[serde(tag = "op", rename_all = "snake_case")]
7pub enum ClientFrame {
8 Subscribe {
9 room_id: Uuid,
10 #[serde(default)]
11 resync: Option<bool>,
12 },
13 Unsubscribe {
14 room_id: Uuid,
15 },
16 Resync {
17 room_id: Uuid,
18 after_seq: i64,
19 },
20 Typing {
21 room_id: Uuid,
22 #[serde(default)]
23 thread_id: Option<Uuid>,
24 },
25 Ping,
26}
27
28#[derive(Debug, Clone, Serialize)]
29#[serde(tag = "type", rename_all = "snake_case")]
30pub enum ServerFrame {
31 Ready {
32 user_id: i64,
33 session_id: Uuid,
34 },
35 Event {
36 room_id: Uuid,
37 event: serde_json::Value,
38 },
39 UserEvent {
40 event: serde_json::Value,
41 },
42 History {
43 room_id: Uuid,
44 messages: Vec<MessageDto>,
45 },
46 Gap {
47 room_id: Uuid,
48 from: i64,
49 to: i64,
50 },
51 ResyncRequired {
52 room_id: Uuid,
53 reason: String,
54 },
55 Error {
56 message: String,
57 },
58 Pong,
59 Bye {
60 message: String,
61 },
62}
Deleted app/im/src/gateway.rs
@@ -1,258 +0,0 @@
1use std::convert::Infallible;
2
3use chat::services::service::ChatService;
4use http_body_util::{BodyExt as _, Empty, Full};
5use hyper::body::{Bytes, Incoming};
6use hyper::header::{
7 HeaderValue, SEC_WEBSOCKET_ACCEPT, SEC_WEBSOCKET_KEY, SEC_WEBSOCKET_PROTOCOL,
8 SEC_WEBSOCKET_VERSION,
9};
10use hyper::{Method, Request, Response, StatusCode};
11use hyper_util::rt::TokioIo;
12use sea_orm::{ConnectionTrait, DbBackend, Statement};
13use tokio_tungstenite::WebSocketStream;
14use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
15use tokio_tungstenite::tungstenite::protocol::{Role, WebSocketConfig};
16use uuid::Uuid;
17
18use crate::jwt::consume_jti;
19use std::sync::{Arc, OnceLock};
20use crate::socket::{ServerWs, handle_socket};
21
22const WS_PATH: &str = "/ws/v1";
23const JWT_PROTOCOL_PREFIX: &str = "gitdata.jwt.";
24
25type BoxBody = http_body_util::combinators::BoxBody<Bytes, Infallible>;
26
27pub async fn route(
28 service: ChatService,
29 mut req: Request<Incoming>,
30) -> Result<Response<BoxBody>, Infallible> {
31 match (req.method(), req.uri().path()) {
32 (&Method::GET, "/healthz") => Ok(text_response(StatusCode::OK, "ok")),
33 (&Method::GET, "/readyz") => Ok(readiness(&service).await),
34 (&Method::GET, WS_PATH) => Ok(handshake(service, &mut req).await),
35 _ => Ok(text_response(StatusCode::NOT_FOUND, "not found")),
36 }
37}
38
39fn text_response(status: StatusCode, body: &'static str) -> Response<BoxBody> {
40 Response::builder()
41 .status(status)
42 .header("content-type", "text/plain; charset=utf-8")
43 .body(Full::new(Bytes::from(body)).boxed())
44 .unwrap()
45}
46
47async fn readiness(service: &ChatService) -> Response<BoxBody> {
48 let db_ok = service
49 .db()
50 .read()
51 .query_one_raw(Statement::from_sql_and_values(
52 DbBackend::Postgres,
53 "SELECT 1",
54 [],
55 ))
56 .await
57 .is_ok();
58 let mut conn = service.state.redis.connection_clone();
59 let redis_ok: bool = match &mut conn {
60 export::RedisConnection::Standalone(c) => {
61 redis::cmd("PING").query_async::<String>(c).await.is_ok()
62 }
63 export::RedisConnection::Cluster(c) => {
64 redis::cmd("PING").query_async::<String>(c).await.is_ok()
65 }
66 };
67 if db_ok && redis_ok {
68 text_response(StatusCode::OK, "ready")
69 } else {
70 text_response(StatusCode::SERVICE_UNAVAILABLE, "not ready")
71 }
72}
73
74fn host_of(raw: &str) -> Option<&str> {
75 let rest = raw.split_once("://").map(|(_, r)| r).unwrap_or(raw);
76 let host = rest.split('/').next()?;
77 let host = host.rsplit_once('@').map(|(_, h)| h).unwrap_or(host);
78 let host = match host.rsplit_once(':') {
79 Some((h, port)) if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) => h,
80 _ => host,
81 };
82 (!host.is_empty()).then_some(host)
83}
84
85fn origin_allowed(service: &ChatService, headers: &hyper::HeaderMap) -> bool {
86 let Some(origin) = headers
87 .get(hyper::header::ORIGIN)
88 .and_then(|v| v.to_str().ok())
89 else {
90 return true;
91 };
92 let Some(origin_host) = host_of(origin) else {
93 return false;
94 };
95 let mut allowed: Vec<&str> = vec![];
96 if let Some(host) = host_of(&service.config().web.web_base_url) {
97 allowed.push(host);
98 }
99 for entry in &service.config().web.cors_allow_origins {
100 if let Some(host) = host_of(entry) {
101 allowed.push(host);
102 }
103 }
104 allowed.contains(&origin_host)
105}
106
107fn extract_jwt(headers: &hyper::HeaderMap) -> Option<String> {
108 for value in headers.get_all(SEC_WEBSOCKET_PROTOCOL) {
109 let Ok(raw) = value.to_str() else {
110 continue;
111 };
112 for proto in raw.split(',') {
113 let proto = proto.trim();
114 if let Some(token) = proto.strip_prefix(JWT_PROTOCOL_PREFIX)
115 && !token.is_empty()
116 {
117 return Some(token.to_string());
118 }
119 }
120 }
121 None
122}
123
124async fn handshake(service: ChatService, req: &mut Request<Incoming>) -> Response<BoxBody> {
125 let headers = req.headers();
126 let bad = |reason: &'static str, origin: Option<String>| {
127 match reason {
128 "origin" => {
129 tracing::warn!(origin = %origin.unwrap_or_default(), "ws reject: origin not allowed")
130 }
131 "missing-protocol" => tracing::warn!("ws reject: jwt subprotocol missing"),
132 "bad-headers" => tracing::warn!("ws reject: invalid websocket handshake headers"),
133 _ => {}
134 }
135 text_response(StatusCode::BAD_REQUEST, "bad request")
136 };
137
138 let key = match headers.get(SEC_WEBSOCKET_KEY).and_then(|v| v.to_str().ok()) {
139 Some(key) => key.to_string(),
140 None => return bad("bad-headers", None),
141 };
142 let version_ok = headers
143 .get(SEC_WEBSOCKET_VERSION)
144 .and_then(|v| v.to_str().ok())
145 == Some("13");
146 let upgrade_ok = headers
147 .get(hyper::header::UPGRADE)
148 .and_then(|v| v.to_str().ok())
149 .is_some_and(|v| v.eq_ignore_ascii_case("websocket"))
150 && headers
151 .get(hyper::header::CONNECTION)
152 .and_then(|v| v.to_str().ok())
153 .unwrap_or_default()
154 .split(',')
155 .any(|token| token.trim().eq_ignore_ascii_case("upgrade"));
156 if !version_ok || !upgrade_ok {
157 return bad("bad-headers", None);
158 }
159 if !origin_allowed(&service, headers) {
160 let origin = headers
161 .get(hyper::header::ORIGIN)
162 .and_then(|v| v.to_str().ok())
163 .map(str::to_string);
164 return bad("origin", origin);
165 }
166 let Some(token) = extract_jwt(headers) else {
167 return bad("missing-protocol", None);
168 };
169 let Some(verifier) = verifier(&service.config().oauth) else {
170 tracing::warn!("ws reject: oauth verifier is unavailable");
171 return text_response(StatusCode::FORBIDDEN, "forbidden");
172 };
173 let claims = match verifier
174 .verify_service(&token, verifier.chat_audience())
175 .await
176 {
177 Ok(claims) => claims,
178 Err(error) => {
179 tracing::warn!(%error, "ws reject: service token verify failed");
180 return text_response(StatusCode::FORBIDDEN, "forbidden");
181 }
182 };
183 let uid = claims.sub;
184 let jti = claims.jti.clone();
185 let token_for_introspect = token.clone();
186
187 let on_upgrade = hyper::upgrade::on(req);
188 let max_frame_bytes = service.config().chat.ws.max_frame_bytes;
189 let response = Response::builder()
190 .status(StatusCode::SWITCHING_PROTOCOLS)
191 .header(hyper::header::CONNECTION, "Upgrade")
192 .header(hyper::header::UPGRADE, "websocket")
193 .header(SEC_WEBSOCKET_ACCEPT, derive_accept_key(key.as_bytes()))
194 .header(
195 SEC_WEBSOCKET_PROTOCOL,
196 HeaderValue::from_str(&format!("{JWT_PROTOCOL_PREFIX}{token}")).unwrap(),
197 )
198 .body(Empty::new().boxed())
199 .unwrap();
200
201 tokio::spawn(async move {
202 let upgraded = match on_upgrade.await {
203 Ok(upgraded) => upgraded,
204 Err(error) => {
205 tracing::debug!(%error, "ws upgrade did not complete");
206 return;
207 }
208 };
209 let config = WebSocketConfig::default()
210 .max_message_size(Some(max_frame_bytes))
211 .max_frame_size(Some(max_frame_bytes));
212 let mut ws: ServerWs =
213 WebSocketStream::from_raw_socket(TokioIo::new(upgraded), Role::Server, Some(config))
214 .await;
215
216 let now = std::time::SystemTime::now()
217 .duration_since(std::time::UNIX_EPOCH)
218 .unwrap_or_default()
219 .as_secs() as i64;
220 if !consume_jti(&service.state.redis, &jti, claims.exp - now).await {
221 tracing::warn!(%jti, "ws reject: jwt already used or redis unavailable");
222 let _ = ws.close(None).await;
223 return;
224 }
225 if let Some(client) = service.state.kenauth.as_ref() {
226 let active = client
227 .introspect_token(&token_for_introspect)
228 .await
229 .map(|response| {
230 response.active
231 && response
232 .principal
233 .as_ref()
234 .is_some_and(|principal| principal.user_id == uid)
235 })
236 .unwrap_or(false);
237 if !active {
238 tracing::warn!(%uid, "ws reject: service token is not active");
239 let _ = ws.close(None).await;
240 return;
241 }
242 }
243 handle_socket(ws, service, uid, Uuid::now_v7()).await;
244 });
245
246 response
247}
248
249static VERIFIER: OnceLock<Option<Arc<jwt::JwtVerifier>>> = OnceLock::new();
250
251fn verifier(config: &config::oauth::OAuthConfig) -> Option<&'static Arc<jwt::JwtVerifier>> {
252 if !config.enabled {
253 return None;
254 }
255 VERIFIER
256 .get_or_init(|| jwt::JwtVerifier::new(config).ok())
257 .as_ref()
258}
Deleted app/im/src/jwt.rs
@@ -1,29 +0,0 @@
1use export::AppRedis;
2
3pub async fn consume_jti(redis: &AppRedis, jti: &str, ttl_secs: i64) -> bool {
4 use export::RedisConnection;
5 let mut conn = redis.connection_clone();
6 let result: Result<Option<String>, redis::RedisError> = match &mut conn {
7 RedisConnection::Standalone(c) => {
8 redis::cmd("SET")
9 .arg(format!("chat:jwt_used:{jti}"))
10 .arg("1")
11 .arg("NX")
12 .arg("EX")
13 .arg(ttl_secs.max(1))
14 .query_async(c)
15 .await
16 }
17 RedisConnection::Cluster(c) => {
18 redis::cmd("SET")
19 .arg(format!("chat:jwt_used:{jti}"))
20 .arg("1")
21 .arg("NX")
22 .arg("EX")
23 .arg(ttl_secs.max(1))
24 .query_async(c)
25 .await
26 }
27 };
28 matches!(result, Ok(Some(_)))
29}
Deleted app/im/src/main.rs
@@ -1,104 +0,0 @@
1mod frames;
2mod gateway;
3mod jwt;
4mod socket;
5
6use std::net::SocketAddr;
7
8use anyhow::Context;
9use chat::services::service::ChatService;
10use config::scoped::{ImConfig, ScopedConfig};
11use hyper_util::rt::TokioIo;
12use tokio::net::TcpListener;
13use tracing::Instrument as _;
14use tracing::info;
15
16#[tokio::main]
17async fn main() -> anyhow::Result<()> {
18 export::telemetry::init_tracing();
19
20 let cfg = ImConfig::load().context("loading configuration")?;
21 if !cfg.im.enabled() {
22 info!("im is disabled by config; exiting");
23 return Ok(());
24 }
25
26 info!("connecting to database / redis / storage / email");
27 let state = export::AppState::with_config(cfg.clone().into_app_config())
28 .await
29 .context("building AppState")?;
30 info!("dependencies ready");
31
32 if let Err(error) = export::telemetry::init_meter_provider() {
33 tracing::warn!(%error, "failed to init meter provider; metrics disabled");
34 }
35
36 let chat = ChatService::new(state);
37
38 let bind_addr = cfg.im.bind_addr().to_string();
39 let port = cfg.im.port();
40 let addr: SocketAddr = format!("{bind_addr}:{port}")
41 .parse()
42 .with_context(|| format!("parsing bind address {bind_addr}:{port}"))?;
43
44 let listener = TcpListener::bind(addr)
45 .await
46 .with_context(|| format!("binding {addr}"))?;
47 info!(%addr, "im listening");
48
49 loop {
50 tokio::select! {
51 _ = export::telemetry::shutdown_signal() => break,
52 accepted = listener.accept() => {
53 let (stream, _peer) = accepted.context("accepting connection")?;
54 let service = chat.clone();
55 tokio::spawn(async move {
56 let io = TokioIo::new(stream);
57 let service = hyper::service::service_fn(move |mut req| {
58 let service = service.clone();
59 let request_id = req
60 .headers()
61 .get(export::middleware::REQUEST_ID_HEADER)
62 .and_then(|value| value.to_str().ok())
63 .filter(|value| !value.is_empty() && value.len() <= 128)
64 .map(str::to_owned)
65 .unwrap_or_else(export::middleware::new_request_id);
66 req.extensions_mut()
67 .insert(export::middleware::RequestId(request_id.clone()));
68 let span = export::telemetry::http_span(&req);
69 if let Some(traceparent) = query_traceparent(req.uri().query()) {
70 export::telemetry::set_parent_from_traceparent(&span, Some(traceparent));
71 }
72 let response_header = request_id.parse().ok();
73 async move {
74 let mut response = gateway::route(service, req).await;
75 if let (Ok(response), Some(header)) = (&mut response, response_header) {
76 response
77 .headers_mut()
78 .insert(export::middleware::REQUEST_ID_HEADER, header);
79 }
80 response
81 }
82 .instrument(span)
83 });
84 if let Err(error) = hyper::server::conn::http1::Builder::new()
85 .serve_connection(io, service)
86 .with_upgrades()
87 .await
88 {
89 tracing::debug!(%error, "im connection closed");
90 }
91 });
92 }
93 }
94 }
95
96 info!("im shut down");
97 Ok(())
98}
99
100fn query_traceparent(query: Option<&str>) -> Option<&str> {
101 query?
102 .split('&')
103 .find_map(|pair| pair.strip_prefix("traceparent="))
104}
Deleted app/im/src/socket.rs
@@ -1,532 +0,0 @@
1use std::collections::HashMap;
2use std::time::Duration;
3
4use chat::errors::ChatError;
5use chat::services::messages::ListMessageQuery;
6use chat::services::service::ChatService;
7use chat::share::realtime::RoomEvent;
8use futures::{SinkExt, StreamExt};
9use hyper::upgrade::Upgraded;
10use hyper_util::rt::TokioIo;
11use tokio::sync::mpsc;
12use tokio_tungstenite::WebSocketStream;
13use tokio_tungstenite::tungstenite::{self, Message};
14use tokio_util::sync::CancellationToken;
15use uuid::Uuid;
16
17use crate::frames::{ClientFrame, ServerFrame};
18
19pub type ServerWs = WebSocketStream<TokioIo<Upgraded>>;
20
21#[derive(Clone)]
22enum Inbox {
23 Room(Uuid, Result<String, ()>),
24 User(String),
25}
26
27struct Subscription {
28 cancel: CancellationToken,
29 handle: tokio::task::JoinHandle<()>,
30}
31
32/// Push into the socket inbox without ever deadlocking the forwarder:
33/// bounded retry, then drop the payload (gap detection resyncs the client).
34async fn best_effort_send(
35 tx: &mpsc::Sender<Inbox>,
36 item: Inbox,
37 cancel: &CancellationToken,
38) -> bool {
39 for _ in 0..1000 {
40 match tx.try_send(item.clone()) {
41 Ok(()) => return true,
42 Err(mpsc::error::TrySendError::Full(_)) => {
43 tokio::select! {
44 _ = cancel.cancelled() => return false,
45 _ = tokio::time::sleep(Duration::from_millis(1)) => {}
46 }
47 }
48 Err(mpsc::error::TrySendError::Closed(_)) => return false,
49 }
50 }
51 false
52}
53
54pub async fn handle_socket(socket: ServerWs, service: ChatService, uid: i64, session_id: Uuid) {
55 let _connection = export::metrics::ImConnectionGuard::new();
56 let (mut sink, mut stream) = socket.split();
57
58 let (inbox_tx, mut inbox_rx) = mpsc::channel::<Inbox>(1024);
59
60 let user_cancel = CancellationToken::new();
61 let mut user_rx = match service.realtime.subscribe_user(uid).await {
62 Ok(rx) => rx,
63 Err(error) => {
64 tracing::warn!(%error, "ws user subscribe failed");
65 let _ = close_with_unsplit(sink, "internal error").await;
66 return;
67 }
68 };
69 let user_handle = {
70 let tx = inbox_tx.clone();
71 let cancel = user_cancel.clone();
72 tokio::spawn(async move {
73 loop {
74 tokio::select! {
75 _ = cancel.cancelled() => break,
76 payload = user_rx.recv() => {
77 let Ok(payload) = payload else { break };
78 if !best_effort_send(&tx, Inbox::User(payload), &cancel).await {
79 break;
80 }
81 }
82 }
83 }
84 })
85 };
86
87 let ready = ServerFrame::Ready {
88 user_id: uid,
89 session_id,
90 };
91 if send_frame(&mut sink, &ready).await.is_err() {
92 user_cancel.cancel();
93 let _ = user_handle.await;
94 service.realtime.unsubscribe_user(uid).await;
95 return;
96 }
97
98 // 在线标记:推送 worker 用它决定"这个用户已经有实时通道,不需要系统
99 // 通知"。同一个账号可以同时开多个连接,每条连接在集合里各占一个成员,
100 // 只有全部断开(或成员过期)才算离线。
101 let online = OnlineMarker::claim(&service, uid).await;
102 // 可见在线状态跟随同一条 WS 生命周期:claim 即上线(用户已有的 Away/
103 // Dnd/隐身状态原样保留、只续期),tick 续期,最后一条连接断开时下线。
104 service.mark_online(uid).await;
105
106 let heartbeat_secs = service.config().chat.ws.heartbeat_secs.max(5);
107 let idle_limit = Duration::from_secs(heartbeat_secs * 3);
108 let mut last_activity = tokio::time::Instant::now();
109 let mut ticker = tokio::time::interval(Duration::from_secs(heartbeat_secs));
110 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
111
112 let mut subscriptions: HashMap<Uuid, Subscription> = HashMap::new();
113 let mut last_seq: HashMap<Uuid, i64> = HashMap::new();
114 let max_subscriptions = service.config().chat.ws.max_subscriptions;
115 let mut last_typing: Option<tokio::time::Instant> = None;
116 let mut tick_count: u32 = 0;
117
118 loop {
119 tokio::select! {
120 frame = stream.next() => {
121 let Some(frame) = frame else { break };
122 let Ok(frame) = frame else { break };
123 last_activity = tokio::time::Instant::now();
124 match frame {
125 Message::Text(text) => {
126 let frame: Result<ClientFrame, _> = serde_json::from_str(&text);
127 match frame {
128 Ok(frame) => {
129 handle_client_frame(
130 &mut sink,
131 &service,
132 uid,
133 frame,
134 &mut subscriptions,
135 &mut last_seq,
136 &mut last_typing,
137 &inbox_tx,
138 max_subscriptions,
139 )
140 .await;
141 }
142 Err(_) => {
143 let _ = send_frame(&mut sink, &ServerFrame::Error {
144 message: "invalid frame".to_string(),
145 }).await;
146 }
147 }
148 }
149 Message::Ping(payload) => {
150 let _ = sink.send(Message::Pong(payload)).await;
151 }
152 Message::Close(_) => break,
153 _ => {}
154 }
155 }
156 inbox = inbox_rx.recv() => {
157 let Some(inbox) = inbox else { break };
158 match inbox {
159 Inbox::Room(room_id, Ok(payload)) => {
160 let gap = check_seq_gap(&payload, &mut last_seq, room_id);
161 if let Some(frame) = gap
162 && send_frame(&mut sink, &frame).await.is_err()
163 {
164 break;
165 }
166 let frame = ServerFrame::Event {
167 room_id,
168 event: serde_json::from_str(&payload)
169 .unwrap_or(serde_json::Value::Null),
170 };
171 if send_frame(&mut sink, &frame).await.is_err() {
172 break;
173 }
174 }
175 Inbox::Room(room_id, Err(())) => {
176 let frame = ServerFrame::ResyncRequired {
177 room_id,
178 reason: "lagged".to_string(),
179 };
180 last_seq.remove(&room_id);
181 if send_frame(&mut sink, &frame).await.is_err() {
182 break;
183 }
184 }
185 Inbox::User(payload) => {
186 let frame = ServerFrame::UserEvent {
187 event: serde_json::from_str(&payload)
188 .unwrap_or(serde_json::Value::Null),
189 };
190 if send_frame(&mut sink, &frame).await.is_err() {
191 break;
192 }
193 }
194 }
195 }
196 _ = ticker.tick() => {
197 if last_activity.elapsed() > idle_limit {
198 let _ = send_frame(&mut sink, &ServerFrame::Bye {
199 message: "idle timeout".to_string(),
200 }).await;
201 break;
202 }
203 // 续期必须在 tick 里做而不是只在 ready 时写一次:TTL 到期而
204 // 进程还活着(例如用户只是在读、没有发帧)时,键消失会让推送
205 // worker 把他当成离线,于是同一个事件既弹 toast 又弹系统通知;
206 // 可见在线状态同样靠这里的心跳保活。
207 online.refresh().await;
208 service.touch_presence(uid).await;
209 tick_count = tick_count.wrapping_add(1);
210 if tick_count.is_multiple_of(2) {
211 let mut revoked: Vec<Uuid> = Vec::new();
212 for &room_id in subscriptions.keys() {
213 if service_can_read(&service, uid, room_id).await.is_err() {
214 revoked.push(room_id);
215 }
216 }
217 for room_id in revoked {
218 if let Some(sub) = subscriptions.remove(&room_id) {
219 sub.cancel.cancel();
220 let _ = sub.handle.await;
221 service.realtime.unsubscribe_room(room_id).await;
222 last_seq.remove(&room_id);
223 if send_frame(&mut sink, &ServerFrame::ResyncRequired {
224 room_id,
225 reason: "access revoked".to_string(),
226 })
227 .await
228 .is_err()
229 {
230 break;
231 }
232 }
233 }
234 }
235 }
236 }
237 }
238
239 // 计数归零=最后一条连接关闭;Redis 故障时 release 返回 None,此时不判定
240 // 离线,交给 TTL 兜底。
241 if online.release().await == Some(0) {
242 service.mark_offline(uid).await;
243 }
244 let mut teardown: Vec<(Uuid, tokio::task::JoinHandle<()>)> = Vec::new();
245 for (room_id, sub) in subscriptions {
246 sub.cancel.cancel();
247 teardown.push((room_id, sub.handle));
248 }
249 user_cancel.cancel();
250 // Releasing the local sender unblocks any forwarder parked on a full
251 // inbox, guaranteeing the awaits below terminate.
252 drop(inbox_tx);
253 let _ = user_handle.await;
254 for (room_id, handle) in teardown {
255 // Await the forwarder so its broadcast receiver is released before
256 // the unsubscribe refcount check.
257 let _ = handle.await;
258 service.realtime.unsubscribe_room(room_id).await;
259 }
260 service.realtime.unsubscribe_user(uid).await;
261}
262
263/// 一条 WS 连接的"在线"占位。
264///
265/// 每条连接是集合里的一个成员(分数=到期时刻):多标签页/多实例各自登记,
266/// 只有全部断开才算离线;进程被强杀留下的成员会随分数过期被惰性清理,不会
267/// 留下永久在线的幽灵,也不会污染在线计数。
268struct OnlineMarker {
269 key: String,
270 member: String,
271 ttl_secs: u64,
272 redis: export::AppRedis,
273}
274
275impl OnlineMarker {
276 async fn claim(service: &ChatService, uid: i64) -> Self {
277 let ttl_secs = service.config().chat.online_ttl_secs();
278 let marker = Self {
279 key: export::push::online_key(uid),
280 // 每条连接一个唯一成员:多标签页/多实例各自登记,互不覆盖。
281 member: Uuid::now_v7().to_string(),
282 ttl_secs,
283 redis: service.state.redis.clone(),
284 };
285 marker.refresh().await;
286 marker
287 }
288
289 /// 登记/续期本连接;过期的旧成员在这里被顺手清掉。续期是幂等的,
290 /// 心跳不会像计数型占位那样把数字越推越高。
291 async fn refresh(&self) {
292 if let Err(error) =
293 export::redis::zset_touch(&self.redis, &self.key, &self.member, self.ttl_secs).await
294 {
295 tracing::warn!(%error, key = %self.key, "online marker refresh failed");
296 }
297 }
298
299 /// 注销本连接,返回剩余**存活**连接数;归零即最后一条连接退出。
300 /// Redis 故障返回 `None`,调用方不应据此判定离线(TTL 会兜底)。
301 async fn release(&self) -> Option<i64> {
302 match export::redis::zset_release(&self.redis, &self.key, &self.member).await {
303 Ok(remaining) => Some(remaining),
304 Err(error) => {
305 tracing::warn!(%error, key = %self.key, "online marker release failed");
306 None
307 }
308 }
309 }
310}
311fn check_seq_gap(
312 payload: &str,
313 last_seq: &mut HashMap<Uuid, i64>,
314 room_id: Uuid,
315) -> Option<ServerFrame> {
316 let event: serde_json::Value = serde_json::from_str(payload).ok()?;
317 let seq = event.get("seq")?.as_i64()?;
318 let last = last_seq.get(&room_id).copied();
319 // Only advance the cursor; edit/pin events may carry older seq values.
320 if last.is_some_and(|l| seq <= l) {
321 return None;
322 }
323 last_seq.insert(room_id, seq);
324 match last {
325 Some(last) if seq > last + 1 => Some(ServerFrame::Gap {
326 room_id,
327 from: last + 1,
328 to: seq,
329 }),
330 _ => None,
331 }
332}
333
334#[allow(clippy::too_many_arguments)]
335async fn handle_client_frame<S>(
336 sink: &mut S,
337 service: &ChatService,
338 uid: i64,
339 frame: ClientFrame,
340 subscriptions: &mut HashMap<Uuid, Subscription>,
341 last_seq: &mut HashMap<Uuid, i64>,
342 last_typing: &mut Option<tokio::time::Instant>,
343 inbox_tx: &mpsc::Sender<Inbox>,
344 max_subscriptions: usize,
345) where
346 S: futures::Sink<Message, Error = tungstenite::Error> + Unpin,
347{
348 match frame {
349 ClientFrame::Subscribe { room_id, resync } => {
350 if subscriptions.contains_key(&room_id) {
351 return;
352 }
353 if subscriptions.len() >= max_subscriptions {
354 let _ = send_frame(
355 sink,
356 &ServerFrame::Error {
357 message: "subscription limit reached".to_string(),
358 },
359 )
360 .await;
361 return;
362 }
363 if service_can_read(service, uid, room_id).await.is_err() {
364 let _ = send_frame(
365 sink,
366 &ServerFrame::Error {
367 message: "forbidden".to_string(),
368 },
369 )
370 .await;
371 return;
372 }
373 let rx = match service.realtime.subscribe_room(room_id).await {
374 Ok(rx) => rx,
375 Err(_) => {
376 let _ = send_frame(
377 sink,
378 &ServerFrame::Error {
379 message: "subscribe failed".to_string(),
380 },
381 )
382 .await;
383 return;
384 }
385 };
386 let cancel = CancellationToken::new();
387 let handle = {
388 let tx = inbox_tx.clone();
389 let child = cancel.clone();
390 let mut rx = rx;
391 tokio::spawn(async move {
392 loop {
393 tokio::select! {
394 _ = child.cancelled() => break,
395 result = rx.recv() => {
396 let item = match result {
397 Ok(payload) => Inbox::Room(room_id, Ok(payload)),
398 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
399 Inbox::Room(room_id, Err(()))
400 }
401 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
402 };
403 // Non-blocking fallback: overflow drops the
404 // frame; seq gap detection triggers resync.
405 if !best_effort_send(&tx, item, &child).await {
406 break;
407 }
408 }
409 }
410 }
411 })
412 };
413 subscriptions.insert(room_id, Subscription { cancel, handle });
414
415 let history_limit = service.config().chat.ws.history_limit;
416 if let Ok(page) = service
417 .list_messages(
418 uid,
419 room_id,
420 ListMessageQuery {
421 before_seq: None,
422 after_seq: None,
423 limit: Some(history_limit),
424 thread_id: None,
425 author_id: None,
426 },
427 )
428 .await
429 {
430 if resync.unwrap_or(true) {
431 let frame = ServerFrame::History {
432 room_id,
433 messages: page.items,
434 };
435 let _ = send_frame(sink, &frame).await;
436 }
437 if let Some(newest) = page.newest_seq {
438 last_seq.insert(room_id, newest);
439 }
440 }
441 }
442 ClientFrame::Unsubscribe { room_id } => {
443 if let Some(sub) = subscriptions.remove(&room_id) {
444 sub.cancel.cancel();
445 let _ = sub.handle.await;
446 last_seq.remove(&room_id);
447 service.realtime.unsubscribe_room(room_id).await;
448 }
449 }
450 ClientFrame::Resync { room_id, after_seq } => {
451 if service_can_read(service, uid, room_id).await.is_err() {
452 return;
453 }
454 if let Ok(page) = service
455 .list_messages(
456 uid,
457 room_id,
458 ListMessageQuery {
459 before_seq: None,
460 after_seq: Some(after_seq),
461 limit: Some(service.config().chat.ws.history_limit),
462 thread_id: None,
463 author_id: None,
464 },
465 )
466 .await
467 {
468 if let Some(newest) = page.newest_seq {
469 last_seq.insert(room_id, newest);
470 }
471 let frame = ServerFrame::History {
472 room_id,
473 messages: page.items,
474 };
475 let _ = send_frame(sink, &frame).await;
476 }
477 }
478 ClientFrame::Typing { room_id, thread_id } => {
479 // Coarse throttle: at most one typing broadcast per 3s per socket.
480 let now = tokio::time::Instant::now();
481 if last_typing.is_some_and(|prev| now.duration_since(prev).as_secs() < 3) {
482 return;
483 }
484 if service_can_read(service, uid, room_id).await.is_ok() {
485 *last_typing = Some(now);
486 let event = RoomEvent::Typing {
487 room_id,
488 user_id: uid,
489 thread_id,
490 ttl_secs: service.config().chat.typing_ttl_secs.max(1),
491 };
492 service.realtime.publish_room(&event).await;
493 }
494 }
495 ClientFrame::Ping => {
496 let _ = send_frame(sink, &ServerFrame::Pong).await;
497 }
498 }
499}
500
501async fn service_can_read(
502 service: &ChatService,
503 uid: i64,
504 room_id: Uuid,
505) -> Result<(), ChatError> {
506 chat::share::lookup::require_room_readable(service, room_id, uid)
507 .await
508 .map(|_| ())
509}
510
511async fn send_frame<S>(sink: &mut S, frame: &ServerFrame) -> Result<(), tungstenite::Error>
512where
513 S: futures::Sink<Message, Error = tungstenite::Error> + Unpin,
514{
515 let payload = match serde_json::to_string(frame) {
516 Ok(payload) => payload,
517 Err(_) => return Ok(()),
518 };
519 sink.send(Message::Text(payload.into())).await
520}
521
522async fn close_with_unsplit<S>(mut sink: S, message: &str) -> Result<(), tungstenite::Error>
523where
524 S: futures::Sink<Message, Error = tungstenite::Error> + Unpin,
525{
526 let frame = ServerFrame::Bye {
527 message: message.to_string(),
528 };
529 let payload = serde_json::to_string(&frame).unwrap_or_default();
530 sink.send(Message::Text(payload.into())).await?;
531 sink.send(Message::Close(None)).await
532}
Deleted app/im/Cargo.toml
@@ -1,28 +0,0 @@
1[package]
2name = "im"
3version.workspace = true
4edition.workspace = true
5publish = false
6
7[dependencies]
8chat = { workspace = true }
9config = { workspace = true }
10export = { workspace = true }
11jwt = { workspace = true }
12anyhow = { workspace = true }
13bytes = { workspace = true }
14futures = { workspace = true }
15http = "1"
16http-body-util = { workspace = true }
17hyper = { workspace = true }
18hyper-util = { workspace = true }
19redis = { workspace = true }
20sea-orm = { workspace = true }
21serde = { workspace = true, features = ["derive"] }
22serde_json = { workspace = true }
23time = { workspace = true }
24tokio = { workspace = true, features = ["signal", "net", "macros", "rt-multi-thread"] }
25tokio-tungstenite = { workspace = true }
26tokio-util = { workspace = true }
27tracing = { workspace = true }
28uuid = { workspace = true }
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 -}}
@@ -62,12 +57,6 @@
6257{{- define "gitdata.env" -}}
6358- name: APP_APIHOST__PORT
6459 value: {{ .Values.apihost.port | quote }}
65- name: APP_IM__PORT
66 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 }}
7160- name: APP_GIT__KENSHELL_ENDPOINT
7261 value: {{ .Values.kenshell.endpoint | quote }}
7362- name: APP_GIT__KENSHELL_RPC_KEY
@@ -87,11 +76,6 @@
8776 value: {{ default $base .Values.config.env.APP_GIT__BASE_URL | quote }}
8877- name: APP_GIT__HTTP_CLONE_BASE_URL
8978 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 }}
9579{{- if .Values.metrics.otlpEndpoint }}
9680- name: OTEL_EXPORTER_OTLP_ENDPOINT
9781 value: {{ .Values.metrics.otlpEndpoint | quote }}
@@ -105,14 +89,12 @@
10589{{- range $k, $v := .Values.config.env }}
10690{{- if not (hasPrefix "APP_WEB__WEB_BASE_URL" $k) }}
10791{{- if not (hasPrefix "APP_GIT__BASE_URL" $k) }}
108{{- if not (hasPrefix "APP_CI__RUNTIME_BASE_URL" $k) }}
10992{{/* config.env 走容器 env:,secret.env 走 envFrom:;同名时 env: 优先级更高,
11093 会静默覆盖 Secret 里的真实值(例如把连接串换成 chart 默认的 redis://redis-master)。
11194 因此这里跳过任何已在 secret.env 中声明的键,让 Secret 成为唯一来源。 */}}
11295{{- if not (hasKey $.Values.secret.env $k) }}
11396- name: {{ $k }}
11497 value: {{ $v | quote }}
115{{- end }}
11698{{- end }}
11799{{- end }}
118100{{- end }}
Deleted charts/core/templates/im.yaml
@@ -1,163 +0,0 @@
1{{- if .Values.im.enabled }}
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 name: {{ printf "%s-im" (include "gitdata.fullname" .) }}
6 labels:
7 {{- include "gitdata.labels" . | nindent 4 }}
8 app.kubernetes.io/component: im
9spec:
10 {{- if .Values.im.autoscaling.enabled }}
11 # 开着 HPA 时不要声明 replicas:HPA 通过 /scale 子资源写这个字段,
12 # kube-controller-manager 于是成为它的 manager;helm 的 server-side apply
13 # 再写一次就会报 conflict with "kube-controller-manager" with subresource
14 # "scale": .spec.replicas —— 升级直接失败;而且就算写进去,也会把已经扩上
15 # 去的副本数硬拉回 minReplicas。省略即让出所有权:SSA 不会因此把字段清成
16 # 默认值,副本数继续由 scale 子资源持有。
17 {{- else }}
18 replicas: {{ .Values.im.replicaCount }}
19 {{- end }}
20 revisionHistoryLimit: 3
21 strategy:
22 type: RollingUpdate
23 rollingUpdate:
24 maxSurge: {{ .Values.im.maxSurge }}
25 maxUnavailable: {{ .Values.im.maxUnavailable }}
26 selector:
27 matchLabels:
28 {{- include "gitdata.selectorLabels" . | nindent 6 }}
29 app.kubernetes.io/component: im
30 template:
31 metadata:
32 labels:
33 {{- include "gitdata.selectorLabels" . | nindent 8 }}
34 app.kubernetes.io/component: im
35 {{- with .Values.podLabels }}
36 {{- toYaml . | nindent 8 }}
37 {{- end }}
38 annotations:
39 checksum/env: {{ toYaml .Values.config.env | sha256sum }}
40 {{- with .Values.podAnnotations }}
41 {{- toYaml . | nindent 8 }}
42 {{- end }}
43 spec:
44 serviceAccountName: {{ include "gitdata.serviceAccountName" . }}
45 {{- include "gitdata.imagePullSecrets" . | nindent 6 }}
46 terminationGracePeriodSeconds: 45
47 securityContext:
48 {{- toYaml .Values.podSecurityContext | nindent 8 }}
49 {{- if or (eq .Values.im.podAntiAffinity "soft") (eq .Values.im.podAntiAffinity "hard") }}
50 affinity:
51 {{- include "gitdata.podAntiAffinity" (dict "ctx" . "mode" .Values.im.podAntiAffinity "component" "im") | nindent 8 }}
52 {{- end }}
53 {{- with .Values.nodeSelector }}
54 nodeSelector:
55 {{- toYaml . | nindent 8 }}
56 {{- end }}
57 {{- with .Values.tolerations }}
58 tolerations:
59 {{- toYaml . | nindent 8 }}
60 {{- end }}
61 containers:
62 - name: im
63 image: {{ include "gitdata.image" . }}
64 imagePullPolicy: {{ .Values.image.pullPolicy }}
65 args: ["im"]
66 {{- include "gitdata.envFrom" . | nindent 10 }}
67 env:
68 {{- include "gitdata.env" . | nindent 12 }}
69 {{- with .Values.im.extraEnv }}
70 {{- toYaml . | nindent 12 }}
71 {{- end }}
72 ports:
73 - name: http
74 containerPort: {{ .Values.im.port }}
75 livenessProbe:
76 httpGet: { path: /healthz, port: http }
77 initialDelaySeconds: 10
78 periodSeconds: 15
79 timeoutSeconds: 3
80 readinessProbe:
81 httpGet: { path: /readyz, port: http }
82 initialDelaySeconds: 5
83 periodSeconds: 10
84 timeoutSeconds: 3
85 failureThreshold: 3
86 lifecycle:
87 preStop:
88 exec:
89 command: ["/bin/sh", "-c", "sleep 5"]
90 securityContext:
91 {{- toYaml .Values.securityContext | nindent 12 }}
92 resources:
93 {{- toYaml .Values.im.resources | nindent 12 }}
94---
95apiVersion: v1
96kind: Service
97metadata:
98 name: {{ printf "%s-im" (include "gitdata.fullname" .) }}
99 labels:
100 {{- include "gitdata.labels" . | nindent 4 }}
101 app.kubernetes.io/component: im
102spec:
103 type: ClusterIP
104 selector:
105 {{- include "gitdata.selectorLabels" . | nindent 4 }}
106 app.kubernetes.io/component: im
107 ports:
108 - name: http
109 port: {{ .Values.im.port }}
110 targetPort: http
111{{- end }}
112{{- if .Values.im.autoscaling.enabled }}
113---
114apiVersion: autoscaling/v2
115kind: HorizontalPodAutoscaler
116metadata:
117 name: {{ printf "%s-im" (include "gitdata.fullname" .) }}
118 labels:
119 {{- include "gitdata.labels" . | nindent 4 }}
120 app.kubernetes.io/component: im
121spec:
122 scaleTargetRef:
123 apiVersion: apps/v1
124 kind: Deployment
125 name: {{ printf "%s-im" (include "gitdata.fullname" .) }}
126 minReplicas: {{ .Values.im.autoscaling.minReplicas }}
127 maxReplicas: {{ .Values.im.autoscaling.maxReplicas }}
128 metrics:
129 - type: Resource
130 resource:
131 name: cpu
132 target:
133 type: Utilization
134 averageUtilization: {{ .Values.im.autoscaling.targetCPUUtilizationPercentage }}
135 {{- if gt (int (.Values.im.autoscaling.targetMemoryUtilizationPercentage | default 0)) 0 }}
136 - type: Resource
137 resource:
138 name: memory
139 target:
140 type: Utilization
141 averageUtilization: {{ .Values.im.autoscaling.targetMemoryUtilizationPercentage }}
142 {{- end }}
143 {{- with .Values.im.autoscaling.behavior }}
144 behavior:
145 {{- toYaml . | nindent 4 }}
146 {{- end }}
147{{- end }}
148{{- if .Values.im.pdb.enabled }}
149---
150apiVersion: policy/v1
151kind: PodDisruptionBudget
152metadata:
153 name: {{ printf "%s-im" (include "gitdata.fullname" .) }}
154 labels:
155 {{- include "gitdata.labels" . | nindent 4 }}
156 app.kubernetes.io/component: im
157spec:
158 minAvailable: {{ .Values.im.pdb.minAvailable }}
159 selector:
160 matchLabels:
161 {{- include "gitdata.selectorLabels" . | nindent 6 }}
162 app.kubernetes.io/component: im
163{{- end }}
Modified charts/core/templates/ingress.yaml View charts/core/templates/ingress.yaml
@@ -41,16 +41,6 @@
4141 - host: {{ .Values.ingress.host | quote }}
4242 http:
4343 paths:
44 {{- if .Values.im.enabled }}
45 # chat WS 长连接网关(最长前缀匹配优先于 "/")
46 - path: /ws/v1
47 pathType: Prefix
48 backend:
49 service:
50 name: {{ printf "%s-im" $fullName }}
51 port:
52 name: http
53 {{- end }}
5444 - path: /
5545 pathType: Prefix
5646 backend:
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 / migrate)
44type: application
55version: 0.1.0
66appVersion: "1.0.0"
Modified charts/core/values.yaml View charts/core/values.yaml
@@ -30,17 +30,15 @@
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"]'
38 # 事件投递:消息事件写入 event_outbox,由 apihost 内嵌 relay 投递到 RocketMQ。
39 APP_ROCKETMQ__ENABLED: "false"
40 APP_ROCKETMQ__NAME_SERVER: "rocketmq-namesrv:9876"
41 APP_ROCKETMQ__TOPIC_PREFIX: "gitdata"
4442 # 认证中心:浏览器走 OIDC,服务间走 gRPC(x-rpc-key)。
4543 APP_OAUTH__ENABLED: "true"
4644 APP_OAUTH__ISSUER: "https://auth.gitdata.ai"
@@ -64,9 +62,6 @@
6462 APP_AUTH__TOTP_ENCRYPTION_KEY: "" # base64 >=32 字节(实测必填)
6563 APP_S3__ACCESS_KEY_ID: ""
6664 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 加密密钥
7065 APP_PUSH__VAPID_PRIVATE_KEY: "" # base64url 32 字节 P-256 标量,Web Push 签名私钥
7166
7267podSecurityContext:
@@ -143,55 +138,8 @@
143138 sshCloneHost: ""
144139 sshClonePort: 22
145140
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
168im:
169 enabled: true
170 replicaCount: 2
171 port: 3001
172 resources:
173 requests: { cpu: 100m, memory: 128Mi }
174 limits: { memory: 512Mi }
175 autoscaling:
176 enabled: false
177 minReplicas: 2
178 maxReplicas: 6
179 targetCPUUtilizationPercentage: 70
180 # 0 disables the memory metric. Against the 128Mi request this is the
181 # connection-count guard: each live socket costs buffers plus its own
182 # pubsub pump, and the limit (512Mi) is far above where a pod should be.
183 targetMemoryUtilizationPercentage: 0
184 behavior: {}
185 maxSurge: 1
186 maxUnavailable: 0
187 podAntiAffinity: soft # soft | hard | ""
188 extraEnv: []
189 pdb:
190 enabled: true
191 minAvailable: 1
192
193141# ---------------------------------------------------------------------------
194# 存储:保留给 search/synchost 的本地缓存目录(bare 仓库已迁到 KenShell)
142# 存储:apihost 使用的本地仓库/对象缓存目录(bare 仓库已迁到 KenShell)
195143# ---------------------------------------------------------------------------
196144persistence:
197145 repos:
@@ -203,7 +151,7 @@
203151 mountPath: /opt/gitdata
204152
205153# ---------------------------------------------------------------------------
206# Ingress:同源 TLS 终结;/ws/v1 -> im,其余 -> apihost。
154# Ingress:同源 TLS 终结,全部路径 -> apihost。
207155# git smart HTTP(*.git)由 KenShell 自己的入口接管,不在本 chart 内。
208156# ---------------------------------------------------------------------------
209157ingress:
@@ -225,38 +173,3 @@
225173metrics:
226174 # OTLP 推送(无 scrape 端点,见 §13)
227175 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:
@@ -120,47 +98,3 @@
12098
12199migrate:
122100 enabled: true
123
124synchost:
125 enabled: true
126 replicaCount: 2
127
128# 独立增量索引 worker(app/searchhost,无 HTTP 端口);索引任务按 CAS 抢占,
129# 与 synchost 自带的 worker 并存安全。
130searchhost:
131 enabled: true
132 replicaCount: 1
133
134im:
135 enabled: true
136 autoscaling:
137 enabled: true
138 minReplicas: 2
139 maxReplicas: 6
140 targetCPUUtilizationPercentage: 70
141 targetMemoryUtilizationPercentage: 80
142 behavior:
143 scaleUp:
144 stabilizationWindowSeconds: 30
145 policies:
146 - type: Percent
147 value: 100
148 periodSeconds: 60
149 - type: Pods
150 value: 2
151 periodSeconds: 60
152 selectPolicy: Max
153 scaleDown:
154 stabilizationWindowSeconds: 600
155 policies:
156 - type: Pods
157 value: 1
158 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 = ""
@@ -197,18 +167,6 @@
197167kenshell_endpoint = "http://127.0.0.1:50091"
198168kenshell_rpc_key = ""
199169kenshell_timeout_secs = 3600
200
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
212170
213171[git.ssh]
214172bind_addr = "0.0.0.0"
@@ -268,68 +226,13 @@
268226http_bind_addr = "0.0.0.0"
269227http_port = 9418
270228
271[im]
272enabled = true
273bind_addr = "0.0.0.0"
274port = 3001
275
276[health]
277enabled = true
278bind_addr = "0.0.0.0"
279
280[health.synchost]
281port = 3002
282
283[ci]
229[rocketmq]
284230enabled = 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
231name_server = "127.0.0.1:9876"
232producer_group = "gitdata"
233topic_prefix = "gitdata"
234send_timeout_ms = 5000
235max_message_bytes = 4194304
333236
334237[push]
335238enabled = 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
@@ -741,9 +741,8 @@
741741 }
742742 }
743743 txn_result?;
744 pending.flush(&self.state.redis).await;
744 pending.flush(&self.state).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?
@@ -1114,9 +1113,8 @@
11141113 })
11151114 })
11161115 .await?;
1117 pending.flush(&self.state.redis).await;
1116 pending.flush(&self.state).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/reminders.rs View lib/chat/src/services/reminders.rs
@@ -276,7 +276,7 @@
276276 }),
277277 )
278278 .await;
279 pending.flush(&self.state.redis).await;
279 pending.flush(&self.state).await;
280280 }
281281 Ok(count)
282282 }
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 }
@@ -820,7 +814,7 @@
820814 Some(caller),
821815 )
822816 .await?;
823 pending.flush(&self.state.redis).await;
817 pending.flush(&self.state).await;
824818 }
825819 let mut uids = vec![row.inviter_id];
826820 if let Some(invitee) = row.invitee_user_id {
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;
@@ -21,7 +20,12 @@
2120
2221impl ChatService {
2322 pub fn new(state: AppState) -> Self {
24 let realtime = Realtime::new(state.redis.clone()).expect("chat realtime client");
23 let realtime = Realtime::new(
24 state.redis.clone(),
25 state.database.write().clone(),
26 state.events_enabled,
27 )
28 .expect("chat realtime client");
2529 Self { state, realtime }
2630 }
2731
@@ -80,14 +84,6 @@
8084 .map_err(ChatError::from)
8185 }
8286
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
9187 pub(crate) async fn send_email(
9288 &self,
9389 to: &str,
@@ -105,4 +101,3 @@
105101 .map_err(|error| ChatError::Internal(format!("email send failed: {error}")))
106102 }
107103}
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;
Added lib/chat/src/share/events.rs View lib/chat/src/share/events.rs
@@ -0,0 +1,109 @@
1use sea_orm::DatabaseConnection;
2use serde_json::Value;
3
4use crate::errors::ChatError;
5use crate::share::realtime::RoomEvent;
6
7pub fn room_kind(event: &RoomEvent) -> &'static str {
8 match event {
9 RoomEvent::MessagePublished { .. } => "chat.message.create",
10 RoomEvent::MessageEdited { .. } => "chat.message.update",
11 RoomEvent::MessageDeleted { .. } => "chat.message.delete",
12 RoomEvent::MessagesDeleted { .. } => "chat.messages.delete",
13 RoomEvent::MessagePinned { pinned: true, .. } => "chat.pin.create",
14 RoomEvent::MessagePinned { pinned: false, .. } => "chat.pin.delete",
15 RoomEvent::ReactionUpdated { .. } => "chat.reaction.update",
16 RoomEvent::Typing { .. } => "chat.typing.create",
17 RoomEvent::RoomUpdated { .. } => "chat.room.update",
18 RoomEvent::MemberJoined { .. } => "chat.member.create",
19 RoomEvent::MemberLeft { .. } => "chat.member.delete",
20 RoomEvent::ThreadUpdated { .. } => "chat.thread.update",
21 RoomEvent::VoicePresence { .. } => "chat.voice.presence",
22 RoomEvent::VoiceHands { .. } => "chat.voice.hand",
23 RoomEvent::VoiceLayout { .. } => "chat.voice.layout",
24 RoomEvent::VoiceModeration { .. } => "chat.voice.moderate",
25 }
26}
27
28pub fn user_kind(event: &Value) -> &'static str {
29 match event.get("kind").and_then(Value::as_str) {
30 Some("mention") => "chat.mention.create",
31 Some("reminder") => "chat.reminder.create",
32 Some("room_added") => "chat.membership.create",
33 Some("read_marker") => "chat.read_state.update",
34 Some("presence") => "chat.presence.update",
35 _ => "chat.notification.create",
36 }
37}
38
39fn room_actor(event: &RoomEvent) -> Option<i64> {
40 match event {
41 RoomEvent::Typing { user_id, .. }
42 | RoomEvent::MemberJoined { user_id, .. }
43 | RoomEvent::MemberLeft { user_id, .. }
44 | RoomEvent::VoiceModeration { user_id, .. } => Some(*user_id),
45 RoomEvent::MessagePublished { message, .. } | RoomEvent::MessageEdited { message, .. } => {
46 message.get("author_id").and_then(Value::as_i64)
47 }
48 _ => None,
49 }
50}
51
52pub async fn enqueue_room(
53 db: &DatabaseConnection,
54 enabled: bool,
55 event: &RoomEvent,
56) -> Result<(), ChatError> {
57 if !enabled {
58 return Ok(());
59 }
60
61 let mut labels = queue::Labels::new().with("room_id", event.room_id());
62 if let Some(actor) = room_actor(event) {
63 labels.insert("user_id", actor);
64 }
65 if let Some(request_id) = export::events::request_id() {
66 labels.insert("request_id", request_id);
67 }
68
69 export::events::enqueue(
70 db,
71 enabled,
72 room_kind(event),
73 labels,
74 event,
75 format!("chat:room:{}", event.room_id()),
76 )
77 .await?;
78 Ok(())
79}
80
81pub async fn enqueue_user(
82 db: &DatabaseConnection,
83 enabled: bool,
84 uid: i64,
85 event: &Value,
86) -> Result<(), ChatError> {
87 if !enabled {
88 return Ok(());
89 }
90
91 let mut labels = queue::Labels::new().with("user_id", uid);
92 if let Some(room_id) = event.get("room_id").and_then(Value::as_str) {
93 labels.insert("room_id", room_id);
94 }
95 if let Some(request_id) = export::events::request_id() {
96 labels.insert("request_id", request_id);
97 }
98
99 export::events::enqueue(
100 db,
101 enabled,
102 user_kind(event),
103 labels,
104 event,
105 format!("chat:user:{uid}"),
106 )
107 .await?;
108 Ok(())
109}
Modified lib/chat/src/share/mod.rs View lib/chat/src/share/mod.rs
@@ -1,4 +1,5 @@
11pub mod authz;
2pub mod events;
23pub mod guard;
34pub mod lookup;
45pub mod mention;
Modified lib/chat/src/share/notify.rs View lib/chat/src/share/notify.rs
@@ -270,7 +270,7 @@
270270 )
271271 .await;
272272 }
273 pending.flush(&service.state.redis).await;
273 pending.flush(&service.state).await;
274274}
275275
276276/// Mention e-mails honour per-room / per-space mute and notification level:
@@ -321,7 +321,6 @@
321321 seq,
322322 message,
323323 };
324 service.realtime.publish_room(&event).await;
325324 if let Ok(payload) = serde_json::to_value(&event) {
326325 crate::services::outbound::enqueue_outbound_event(
327326 service,
@@ -331,6 +330,7 @@
331330 )
332331 .await;
333332 }
333 service.realtime.publish_room(&event).await;
334334}
335335
336336pub async fn advance_read_state(
Modified lib/chat/src/share/realtime.rs View lib/chat/src/share/realtime.rs
@@ -132,6 +132,8 @@
132132struct Inner {
133133 redis: export::AppRedis,
134134 pubsub_client: redis::Client,
135 db: sea_orm::DatabaseConnection,
136 events_enabled: bool,
135137 rooms: Mutex<HashMap<Uuid, ChannelEntry>>,
136138 users: Mutex<HashMap<i64, ChannelEntry>>,
137139}
@@ -142,7 +144,11 @@
142144}
143145
144146impl Realtime {
145 pub fn new(redis: export::AppRedis) -> Result<Self, ChatError> {
147 pub fn new(
148 redis: export::AppRedis,
149 db: sea_orm::DatabaseConnection,
150 events_enabled: bool,
151 ) -> Result<Self, ChatError> {
146152 let url = redis.config().url().to_string();
147153 let first = url
148154 .split(',')
@@ -154,6 +160,8 @@
154160 inner: Arc::new(Inner {
155161 redis,
156162 pubsub_client,
163 db,
164 events_enabled,
157165 rooms: Mutex::new(HashMap::new()),
158166 users: Mutex::new(HashMap::new()),
159167 }),
@@ -161,10 +169,27 @@
161169 }
162170
163171 pub async fn publish_room(&self, event: &RoomEvent) {
172 if let Err(error) =
173 crate::share::events::enqueue_room(&self.inner.db, self.inner.events_enabled, event)
174 .await
175 {
176 tracing::warn!(%error, room_id = %event.room_id(), "chat room event enqueue failed");
177 }
164178 self.publish(&room_channel(event.room_id()), event).await;
165179 }
166180
167181 pub async fn publish_user<T: Serialize>(&self, uid: i64, event: &T) {
182 if let Ok(value) = serde_json::to_value(event)
183 && let Err(error) = crate::share::events::enqueue_user(
184 &self.inner.db,
185 self.inner.events_enabled,
186 uid,
187 &value,
188 )
189 .await
190 {
191 tracing::warn!(%error, %uid, "chat user event enqueue failed");
192 }
168193 self.publish(&user_channel(uid), event).await;
169194 }
170195
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,7 @@
1010i18n = { workspace = true }
1111imaging = { workspace = true }
1212models = { workspace = true }
13search = { workspace = true }
13queue = { workspace = true }
1414social = { workspace = true }
1515jwt = { workspace = true }
1616kenauth = { 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

Diff too large to display.

Deleted lib/ci/src/triggers.rs

Diff too large to display.

Deleted lib/ci/src/workflows.rs

Diff too large to display.

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

Diff too large to display.

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

Diff too large to display.

Deleted lib/config/src/im.rs

Diff too large to display.

Deleted lib/config/src/import.rs

Diff too large to display.

Modified lib/config/src/lib.rs View lib/config/src/lib.rs
@@ -1,14 +1,10 @@
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;
10pub mod im;
11pub mod import;
128pub mod internal;
139pub mod issues;
1410pub mod livekit;
@@ -17,14 +13,12 @@
1713pub mod push;
1814pub mod quota;
1915pub mod redis;
20pub mod runner;
16pub mod rocketmq;
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,10 @@
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;
43use crate::im::ImServerConfig;
44use crate::import::ImportConfig;
4535use crate::internal::InternalConfig;
4636use crate::issues::IssueConfig;
4737use crate::livekit::LiveKitConfig;
@@ -49,13 +39,11 @@
4939use crate::pow::PowConfig;
5040use crate::push::PushConfig;
5141use crate::redis::RedisConfig;
52use crate::runner::RunnerConfig;
42use crate::rocketmq::RocketMqConfig;
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,
@@ -80,12 +66,8 @@
8066 pub email: EmailConfig,
8167 pub apihost: ApihostConfig,
8268 pub githost: GithostConfig,
83 pub im: ImServerConfig,
84 pub health: HealthConfig,
69 pub rocketmq: RocketMqConfig,
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)?;
107 self.im.is_valid().map_err(ConfigError::Validation)?;
108 self.health.is_valid().map_err(ConfigError::Validation)?;
87 self.rocketmq.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)?;
Added lib/config/src/rocketmq.rs View lib/config/src/rocketmq.rs

Diff too large to display.

Deleted lib/config/src/runner.rs

Diff too large to display.

Modified lib/config/src/scoped.rs View lib/config/src/scoped.rs
@@ -4,15 +4,11 @@
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;
14use crate::im::ImServerConfig;
15use crate::import::ImportConfig;
1612use crate::issues::IssueConfig;
1713use crate::livekit::LiveKitConfig;
1814use crate::oauth::OAuthConfig;
@@ -20,12 +16,11 @@
2016use crate::push::PushConfig;
2117use crate::quota::QuotaConfig;
2218use crate::redis::RedisConfig;
19use crate::rocketmq::RocketMqConfig;
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,17 +102,14 @@
176102 pub email: EmailConfig,
177103 pub oauth: OAuthConfig,
178104 pub apihost: ApihostSection,
179 pub search: SearchConfig,
180 pub import: ImportConfig,
181105 pub livekit: LiveKitConfig,
106 pub rocketmq: RocketMqConfig,
182107 pub push: PushConfig,
183108}
184109
185110impl ApihostConfig {
186111 pub fn into_app_config(self) -> AppConfig {
187112 AppConfig {
188 ci: self.ci,
189 webhook: self.webhook,
190113 s3: self.s3,
191114 redis: self.redis,
192115 chat: self.chat,
@@ -202,9 +125,8 @@
202125 email: self.email,
203126 oauth: self.oauth,
204127 apihost: self.apihost,
205 search: self.search,
206 import: self.import,
207128 livekit: self.livekit,
129 rocketmq: self.rocketmq,
208130 push: self.push,
209131 ..Default::default()
210132 }
@@ -213,59 +135,15 @@
213135
214136impl ScopedConfig for ApihostConfig {
215137 fn validate_scoped(&self) -> Result<(), String> {
216 self.ci.validate()?;
217138 self.redis.validate()?;
218139 self.database.validate()?;
219140 self.pow.validate()?;
220141 self.auth.validate()?;
221142 self.chat.is_valid()?;
222 self.search.validate()?;
223 self.import.validate()?;
224143 self.apihost.is_valid()?;
225144 self.livekit.is_valid()?;
226145 self.oauth.is_valid()?;
146 self.rocketmq.is_valid()?;
227147 self.push.is_valid()
228 }
229}
230
231#[derive(Clone, Debug, Default, Deserialize)]
232#[serde(default)]
233pub struct ImConfig {
234 pub database: DatabaseConfig,
235 pub redis: RedisConfig,
236 pub s3: S3Config,
237 pub email: EmailConfig,
238 pub oauth: OAuthConfig,
239 pub chat: ChatConfig,
240 pub web: WebConfig,
241 pub auth: AuthConfig,
242 pub im: ImServerConfig,
243}
244
245impl ImConfig {
246 pub fn into_app_config(self) -> AppConfig {
247 AppConfig {
248 database: self.database,
249 redis: self.redis,
250 s3: self.s3,
251 email: self.email,
252 oauth: self.oauth,
253 chat: self.chat,
254 web: self.web,
255 auth: self.auth,
256 im: self.im,
257 ..Default::default()
258 }
259 }
260}
261
262impl ScopedConfig for ImConfig {
263 fn validate_scoped(&self) -> Result<(), String> {
264 self.database.validate()?;
265 self.redis.validate()?;
266 self.auth.validate()?;
267 self.chat.is_valid()?;
268 self.oauth.is_valid()?;
269 self.im.is_valid()
270148 }
271149}
Deleted lib/config/src/search.rs

Diff too large to display.

Deleted lib/config/src/webhook.rs

Diff too large to display.

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 }
Added lib/export/src/events.rs View lib/export/src/events.rs

Diff too large to display.

Modified lib/export/src/lib.rs View lib/export/src/lib.rs
@@ -1,6 +1,7 @@
11mod api_error;
22pub mod database;
33pub mod error;
4pub mod events;
45pub mod health;
56pub mod imaging;
67pub mod metrics;
@@ -35,6 +36,7 @@
3536 pub config: AppConfig,
3637 pub kenauth: Option<Arc<kenauth::KenAuthClient>>,
3738 pub email_rpc: Option<Arc<kenauth::KenAuthClient>>,
39 pub events_enabled: bool,
3840}
3941
4042impl AppState {
@@ -76,6 +78,8 @@
7678 kenauth.clone()
7779 };
7880
81 let events_enabled = config.rocketmq.enabled;
82
7983 Ok(Self {
8084 database,
8185 redis,
@@ -83,6 +87,7 @@
8387 config,
8488 kenauth,
8589 email_rpc,
90 events_enabled,
8691 })
8792 }
8893}
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,9 +1,9 @@
11use std::sync::{Arc, Mutex};
22
3use queue::Labels;
34use serde_json::Value;
4use uuid::Uuid;
55
6use crate::redis::AppRedis;
6use crate::AppState;
77
88/// The chat WS per-user channel; every crate that writes a notification
99/// publishes to it so connected clients receive the row in real time.
@@ -18,7 +18,8 @@
1818 })
1919}
2020
21pub async fn publish_user_event(redis: &AppRedis, uid: i64, event: &Value) {
21pub async fn publish_user_event(state: &AppState, uid: i64, event: &Value) {
22 let redis = &state.redis;
2223 let payload = match serde_json::to_string(event) {
2324 Ok(payload) => payload,
2425 Err(error) => {
@@ -33,17 +34,33 @@
3334 // Every `notification` user event is also a candidate for an OS push.
3435 // Enqueueing here — rather than at each of the nine writers — is what makes
3536 // "websocket fan-out and push fan-out never drift" true by construction.
36 if event.get("kind").and_then(Value::as_str) == Some("notification")
37 && let Some(notification) = event.get("notification")
38 && let Some(job) = crate::push::PushJob::from_notification(uid, notification)
39 {
40 crate::push::enqueue(redis, &job).await;
37 if event.get("kind").and_then(Value::as_str) == Some("notification") {
38 if let Some(notification) = event.get("notification")
39 && let Some(job) = crate::push::PushJob::from_notification(uid, notification)
40 {
41 crate::push::enqueue(redis, &job).await;
42 }
43 let labels = Labels::new()
44 .with("user_id", uid)
45 .with_opt("request_id", crate::events::request_id());
46 if let Err(error) = crate::events::enqueue(
47 state.database.write(),
48 state.events_enabled,
49 "notify.notification.create",
50 labels,
51 event,
52 format!("notify:user:{uid}"),
53 )
54 .await
55 {
56 tracing::warn!(%error, %uid, "notification event enqueue failed");
57 }
4158 }
4259}
4360
44pub async fn publish_notification(redis: &AppRedis, uid: i64, notification: Value) {
61pub async fn publish_notification(state: &AppState, uid: i64, notification: Value) {
4562 let event = notification_event_json(notification);
46 publish_user_event(redis, uid, &event).await;
63 publish_user_event(state, uid, &event).await;
4764}
4865
4966/// Notifications created inside a DB transaction; flush after commit.
@@ -62,13 +79,13 @@
6279 }
6380 }
6481
65 pub async fn flush(&self, redis: &AppRedis) {
82 pub async fn flush(&self, state: &AppState) {
6683 let batch: Vec<(i64, Value)> = match self.inner.lock() {
6784 Ok(mut pending) => std::mem::take(&mut *pending),
6885 Err(_) => return,
6986 };
7087 for (uid, event) in batch {
71 publish_user_event(redis, uid, &event).await;
88 publish_user_event(state, uid, &event).await;
7289 }
7390 }
7491}
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/export/Cargo.toml View lib/export/Cargo.toml
@@ -5,6 +5,8 @@
55
66[dependencies]
77config = { workspace = true }
8models = { workspace = true }
9queue = { workspace = true }
810storage = { workspace = true }
911sea-orm = { workspace = true }
1012redis = { workspace = true }
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

Diff too large to display.

Deleted lib/git/src/sync/event.rs

Diff too large to display.

Deleted lib/git/src/sync/mod.rs

Diff too large to display.

Deleted lib/git/src/sync/queue.rs

Diff too large to display.

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

Diff too large to display.

Deleted lib/import/src/handlers/create_import.rs

Diff too large to display.

Deleted lib/import/src/handlers/get_import.rs

Diff too large to display.

Deleted lib/import/src/handlers/list_imports.rs

Diff too large to display.

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

Diff too large to display.

Deleted lib/import/src/handlers/probe_import.rs

Diff too large to display.

Deleted lib/import/src/handlers/requeue_imports.rs

Diff too large to display.

Deleted lib/import/src/handlers/retry_import.rs

Diff too large to display.

Deleted lib/import/src/providers/gitea.rs

Diff too large to display.

Deleted lib/import/src/providers/github.rs

Diff too large to display.

Deleted lib/import/src/providers/gitlab.rs

Diff too large to display.

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

Diff too large to display.

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

Diff too large to display.

Deleted lib/import/src/identity.rs

Diff too large to display.

Deleted lib/import/src/lib.rs

Diff too large to display.

Deleted lib/import/src/openapi.rs

Diff too large to display.

Deleted lib/import/src/routes.rs

Diff too large to display.

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

Diff too large to display.

Modified lib/issues/src/services/comments.rs View lib/issues/src/services/comments.rs
@@ -238,7 +238,7 @@
238238 })
239239 })
240240 .await?;
241 pending.flush(&self.state.redis).await;
241 pending.flush(&self.state).await;
242242
243243 let mut email_recipients = Vec::new();
244244 if let Some(pp_uid) = parent_poster_id {
@@ -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
@@ -664,7 +664,7 @@
664664 })
665665 })
666666 .await?;
667 pending.flush(&self.state.redis).await;
667 pending.flush(&self.state).await;
668668
669669 if !mentioned_uids.is_empty() {
670670 let emails = self.primary_emails(&mentioned_uids).await?;
@@ -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/members.rs View lib/issues/src/services/members.rs
@@ -157,7 +157,7 @@
157157 })
158158 })
159159 .await?;
160 pending.flush(&self.state.redis).await;
160 pending.flush(&self.state).await;
161161
162162 if kind_enum == IssueMemberKind::Assignee && target_uid != caller {
163163 let emails = self.primary_emails(&[target_uid]).await?;
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],
@@ -115,6 +110,15 @@
115110 new_title,
116111 content,
117112 } = detail;
113 let payload = serde_json::json!({
114 "issue_id": issue_id,
115 "user_id": user_id,
116 "event_type": event_type,
117 "comment_id": &comment_id,
118 "old_title": &old_title,
119 "new_title": &new_title,
120 "content": &content,
121 });
118122 issue_event_line::ActiveModel {
119123 id: Set(Uuid::now_v7()),
120124 issue_id: Set(Some(issue_id)),
@@ -132,6 +136,22 @@
132136 }
133137 .insert(txn)
134138 .await?;
139
140 let mut labels = queue::Labels::new()
141 .with("issue_id", issue_id)
142 .with("user_id", user_id);
143 if let Some(request_id) = export::events::request_id() {
144 labels.insert("request_id", request_id);
145 }
146 export::events::enqueue(
147 txn,
148 self.state.events_enabled,
149 event_kind(event_type),
150 labels,
151 payload,
152 format!("issue:{issue_id}"),
153 )
154 .await?;
135155 Ok(())
136156 }
137157
@@ -266,3 +286,31 @@
266286 }
267287}
268288
289fn event_kind(event_type: IssueEventType) -> &'static str {
290 match event_type {
291 IssueEventType::Created => "issue.issue.create",
292 IssueEventType::TitleUpdated => "issue.title.update",
293 IssueEventType::DescriptionUpdated => "issue.description.update",
294 IssueEventType::ContentUpdated => "issue.content.update",
295 IssueEventType::MilestoneUpdated => "issue.milestone.update",
296 IssueEventType::PriorityUpdated => "issue.priority.update",
297 IssueEventType::DueUpdated => "issue.due.update",
298 IssueEventType::Closed => "issue.issue.close",
299 IssueEventType::Reopened => "issue.issue.reopen",
300 IssueEventType::CommentCreated => "issue.comment.create",
301 IssueEventType::CommentUpdated => "issue.comment.update",
302 IssueEventType::CommentDeleted => "issue.comment.delete",
303 IssueEventType::MemberAdded => "issue.member.create",
304 IssueEventType::MemberRemoved => "issue.member.delete",
305 IssueEventType::LabelAdded => "issue.label.create",
306 IssueEventType::LabelRemoved => "issue.label.delete",
307 IssueEventType::DependencyAdded => "issue.dependency.create",
308 IssueEventType::DependencyRemoved => "issue.dependency.delete",
309 IssueEventType::Pinned => "issue.pin.create",
310 IssueEventType::Unpinned => "issue.pin.delete",
311 IssueEventType::RepoRelationAdded => "issue.repo-relation.create",
312 IssueEventType::RepoRelationRemoved => "issue.repo-relation.delete",
313 IssueEventType::PullLinked => "issue.pull-link.create",
314 IssueEventType::PullUnlinked => "issue.pull-link.delete",
315 }
316}
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
@@ -9,6 +9,7 @@
99export = { workspace = true }
1010i18n = { workspace = true }
1111models = { workspace = true }
12queue = { workspace = true }
1213jwt = { workspace = true }
1314kenauth = { workspace = true }
1415space = { workspace = true }
@@ -21,4 +22,3 @@
2122uuid = { workspace = true }
2223time = { workspace = true }
2324tracing = { 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.

Added lib/models/src/system/event_outbox.rs View lib/models/src/system/event_outbox.rs

Diff too large to display.

Modified lib/models/src/system/mod.rs View lib/models/src/system/mod.rs
@@ -1,3 +1,4 @@
1pub mod event_outbox;
12pub mod system_announcements;
23pub mod system_permissions;
34pub mod system_rate_limits;
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
@@ -294,36 +252,13 @@
294252 END IF;
295253END $$;
296254
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
307255-- ---------------------------------------------------------------------------
308-- search index queue: coalesce duplicate queued jobs per (kind, target) and
309-- arrive-once state per repository for incremental code indexing.
256-- event_outbox: relay claims un-published rows; the partial indexes keep the
257-- claim query and per-ordering-key head lookup cheap as the table grows.
310258-- ---------------------------------------------------------------------------
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;
328 END IF;
329END $$;
259CREATE INDEX IF NOT EXISTS idx_event_outbox_pending
260 ON event_outbox (available_at, occurred_at, id) WHERE published_at IS NULL;
261CREATE INDEX IF NOT EXISTS idx_event_outbox_ordering
262 ON event_outbox (ordering_key, occurred_at, id) WHERE published_at IS NULL;
263CREATE INDEX IF NOT EXISTS idx_event_outbox_published
264 ON event_outbox (published_at) WHERE published_at IS NOT NULL;
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;
@@ -147,6 +146,7 @@
147146 if moved {
148147 write_event(
149148 self.db_write(),
149 self.state.events_enabled,
150150 &updated,
151151 actor,
152152 EVENT_HEAD_UPDATED,
@@ -154,8 +154,6 @@
154154 None,
155155 )
156156 .await?;
157 self.publish_ci_event_for(&updated, actor, EVENT_HEAD_UPDATED)
158 .await;
159157 }
160158 Ok((
161159 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
@@ -49,6 +49,7 @@
4949
5050pub async fn write_event(
5151 db: &impl ConnectionTrait,
52 enabled: bool,
5253 pull: &pull_requests::Model,
5354 actor: Option<i64>,
5455 event_type: &str,
@@ -59,6 +60,13 @@
5960 Some(actor) => actor,
6061 None => pull.author_id,
6162 };
63 let payload = serde_json::json!({
64 "pull_request_id": pull.id,
65 "user_id": actor,
66 "event_type": event_type,
67 "commit_sha": &commit_sha,
68 "content": &content,
69 });
6270 let row = pull_request_event_line::ActiveModel {
6371 id: Set(Uuid::now_v7()),
6472 pull_request_id: Set(pull.id),
@@ -69,7 +77,50 @@
6977 created_at: Set(OffsetDateTime::now_utc()),
7078 };
7179 row.insert(db).await?;
72 Ok(())
80
81 let Some(kind) = event_kind(event_type) else {
82 return Ok(());
83 };
84 let mut labels = queue::Labels::new()
85 .with("pull_id", pull.id)
86 .with("user_id", actor);
87 if let Some(request_id) = export::events::request_id() {
88 labels.insert("request_id", request_id);
89 }
90 export::events::enqueue(
91 db,
92 enabled,
93 kind,
94 labels,
95 payload,
96 format!("pull:{}", pull.id),
97 )
98 .await
99}
100
101fn event_kind(event_type: &str) -> Option<&'static str> {
102 Some(match event_type {
103 EVENT_CREATED => "pull.pull.create",
104 EVENT_UPDATED => "pull.pull.update",
105 EVENT_CLOSED => "pull.pull.close",
106 EVENT_REOPENED => "pull.pull.reopen",
107 EVENT_MERGED => "pull.pull.merge",
108 EVENT_COMMENTED => "pull.comment.create",
109 EVENT_REVIEW_SUBMITTED => "pull.review.create",
110 EVENT_REVIEW_DISMISSED => "pull.review.delete",
111 EVENT_REVIEW_REQUESTED => "pull.review-request.create",
112 EVENT_REVIEW_REQUEST_CANCELLED => "pull.review-request.delete",
113 EVENT_HEAD_UPDATED => "pull.head.update",
114 EVENT_LABELED => "pull.label.create",
115 EVENT_UNLABELED => "pull.label.delete",
116 EVENT_ASSIGNED => "pull.assignee.create",
117 EVENT_UNASSIGNED => "pull.assignee.delete",
118 EVENT_LOCKED => "pull.lock.create",
119 EVENT_UNLOCKED => "pull.lock.delete",
120 EVENT_AUTO_MERGE_ENABLED => "pull.auto-merge.create",
121 EVENT_AUTO_MERGE_DISABLED => "pull.auto-merge.delete",
122 _ => return None,
123 })
73124}
74125
75126impl PullService {
@@ -83,6 +134,7 @@
83134 ) -> Result<(), PullError> {
84135 write_event(
85136 self.db_write(),
137 self.state.events_enabled,
86138 pull,
87139 actor,
88140 event_type,
@@ -90,84 +142,7 @@
90142 content,
91143 )
92144 .await?;
93 self.publish_ci_event(pull, actor, event_type).await;
94145 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)
171146 }
172147
173148 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
@@ -403,6 +403,7 @@
403403 let pull = created.insert(&txn).await?;
404404 write_event(
405405 &txn,
406 self.state.events_enabled,
406407 &pull,
407408 Some(caller),
408409 EVENT_CREATED,
@@ -412,9 +413,6 @@
412413 .await?;
413414 txn.commit().await?;
414415 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;
418416 Ok(pull)
419417 }
420418
@@ -502,7 +500,6 @@
502500 let updated = model.update(self.db_write()).await?;
503501 self.record_event(&updated, Some(caller), EVENT_UPDATED, None, None)
504502 .await?;
505 self.queue_search_pull(updated.id, updated.repo_id).await?;
506503 let _ = (space_name, repo_name);
507504 Ok(updated)
508505 }
@@ -541,7 +538,6 @@
541538 )
542539 .await?;
543540 self.recount_open(updated.repo_id).await?;
544 self.queue_search_pull(updated.id, updated.repo_id).await?;
545541 Ok(updated)
546542 }
547543
@@ -587,7 +583,6 @@
587583 .await?;
588584 txn.commit().await?;
589585 self.recount_open(pull.repo_id).await?;
590 self.queue_search_pull(pull.id, pull.repo_id).await?;
591586 Ok(())
592587 }
593588
@@ -678,7 +673,6 @@
678673 self.record_event(&updated, Some(caller), EVENT_MERGED, Some(merged_sha), None)
679674 .await?;
680675 self.recount_open(updated.repo_id).await?;
681 self.queue_search_pull(updated.id, updated.repo_id).await?;
682676 Ok(updated)
683677 }
684678}
@@ -709,7 +703,6 @@
709703 None,
710704 )
711705 .await?;
712 self.queue_search_pull(updated.id, updated.repo_id).await?;
713706 Ok(updated)
714707 }
715708}
Modified lib/pulls/src/services/reviewers.rs View lib/pulls/src/services/reviewers.rs
@@ -130,6 +130,7 @@
130130 }
131131 write_event(
132132 self.db_write(),
133 self.state.events_enabled,
133134 pull,
134135 Some(caller),
135136 EVENT_REVIEW_REQUESTED,
@@ -163,6 +164,7 @@
163164 .await?;
164165 write_event(
165166 self.db_write(),
167 self.state.events_enabled,
166168 pull,
167169 Some(caller),
168170 EVENT_REVIEW_REQUEST_CANCELLED,
Modified lib/pulls/src/services/reviews.rs View lib/pulls/src/services/reviews.rs
@@ -138,6 +138,7 @@
138138 if state != ReviewState::Pending {
139139 write_event(
140140 &txn,
141 self.state.events_enabled,
141142 pull,
142143 Some(caller),
143144 EVENT_REVIEW_SUBMITTED,
@@ -151,7 +152,6 @@
151152 self.sync_reviewer(pull.id, caller, ReviewerOutcome::from_state(state))
152153 .await?;
153154 }
154 self.queue_search_pull(pull.id, pull.repo_id).await?;
155155 let _ = ctx;
156156 Ok(review)
157157 }
@@ -207,7 +207,6 @@
207207 .await?;
208208 self.sync_reviewer(pull.id, caller, ReviewerOutcome::from_state(target))
209209 .await?;
210 self.queue_search_pull(pull.id, pull.repo_id).await?;
211210 Ok(updated)
212211 }
213212
@@ -240,7 +239,6 @@
240239 .await?;
241240 self.sync_reviewer(pull.id, review.user_id, ReviewerOutcome::Dismissed)
242241 .await?;
243 self.queue_search_pull(pull.id, pull.repo_id).await?;
244242 Ok(updated)
245243 }
246244
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
@@ -9,6 +9,7 @@
99git = { workspace = true }
1010issues = { workspace = true }
1111models = { workspace = true }
12queue = { workspace = true }
1213jwt = { workspace = true }
1314space = { workspace = true }
1415axum = { workspace = true }
@@ -20,9 +21,6 @@
2021time = { workspace = true }
2122tracing = { workspace = true }
2223globset = { workspace = true }
23search = { workspace = true }
24ci-protocol = { workspace = true }
25ci = { workspace = true }
2624
2725
2826
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
Added lib/queue/src/conn.rs View lib/queue/src/conn.rs

Diff too large to display.

Added lib/queue/src/envelope.rs View lib/queue/src/envelope.rs

Diff too large to display.

Added lib/queue/src/error.rs View lib/queue/src/error.rs

Diff too large to display.

Added lib/queue/src/lib.rs View lib/queue/src/lib.rs

Diff too large to display.

Added lib/queue/src/publisher.rs View lib/queue/src/publisher.rs

Diff too large to display.

Added lib/queue/src/relay.rs View lib/queue/src/relay.rs

Diff too large to display.

Added lib/queue/src/route.rs View lib/queue/src/route.rs

Diff too large to display.

Added lib/queue/src/subject.rs View lib/queue/src/subject.rs

Diff too large to display.

Added lib/queue/Cargo.toml View lib/queue/Cargo.toml

Diff too large to display.

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/invites.rs View lib/space/src/services/invites.rs
@@ -297,7 +297,7 @@
297297 }
298298 .insert(self.db().write())
299299 .await?;
300 export::notify::publish_notification(&self.state.redis, invitee, row.to_event_value())
300 export::notify::publish_notification(&self.state, invitee, row.to_event_value())
301301 .await;
302302 }
303303
Modified lib/space/src/services/join_requests.rs View lib/space/src/services/join_requests.rs
@@ -234,7 +234,7 @@
234234 }
235235 .insert(self.db().write())
236236 .await?;
237 export::notify::publish_notification(&self.state.redis, recipient, row.to_event_value())
237 export::notify::publish_notification(&self.state, recipient, row.to_event_value())
238238 .await;
239239 Ok(())
240240 }
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());
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
@@ -28,27 +28,17 @@
2828$Services = [ordered]@{
2929 migrate = @{ Exe = 'migrate'; Port = 0; Health = $false }
3030 apihost = @{ Exe = 'apihost'; Port = 4081; Health = $true }
31 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 }
3531 web = @{ Bun = 'web'; Port = 5173; Health = $false }
3632}
37$Order = @('migrate', 'apihost', 'im', 'synchost', 'searchhost', 'webhook', 'web')
38$Backends = @('migrate', 'apihost', 'im', 'synchost', 'searchhost', 'webhook')
33$Order = @('migrate', 'apihost', 'web')
34$Backends = @('migrate', 'apihost')
3935$Aliases = @{
4036 api = 'apihost'
41 chat = 'im'
42 sync = 'synchost'
43 hook = 'webhook'
4437 ui = 'web'
4538}
4639
4740$Urls = [ordered]@{
4841 apihost = 'http://127.0.0.1:4081 (Swagger: /api/docs)'
49 im = 'ws://127.0.0.1:4082'
50 synchost = 'http://127.0.0.1:4083/healthz'
51 webhook = 'http://127.0.0.1:3005'
5242 web = 'http://127.0.0.1:5173'
5343}
5444
@@ -56,7 +46,6 @@
5646 @{ Name = 'postgresql'; Port = 5432 },
5747 @{ Name = 'redis8'; Port = 6379 },
5848 @{ Name = 'rustfs'; Port = 9000 },
59 @{ Name = 'meilisearch'; Port = 7700 },
6049 @{ Name = 'livekit'; Port = 7880 }
6150)
6251
@@ -328,7 +317,7 @@
328317 }
329318 if (-not $ok) {
330319 Write-Info ''
331 Write-Info 'just infra 启动 WSL 侧 PostgreSQL / Redis / RustFS / Meilisearch'
320 Write-Info 'just infra 启动 WSL 侧 PostgreSQL / Redis / RustFS'
332321 Write-Info 'just build 构建全部后端二进制(前端由 build.rs 自动构建并内嵌)'
333322 }
334323}
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 web"
30BACKENDS="apihost"
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')"
@@ -52,9 +52,6 @@
5252port_of() {
5353 case "$1" in
5454 apihost) echo 4081 ;;
55 im) echo 4082 ;;
56 synchost) echo 4083 ;;
57 webhook) echo 3005 ;;
5855 web) echo 5173 ;;
5956 *) echo 0 ;;
6057 esac
@@ -69,9 +66,6 @@
6966alias_of() {
7067 case "$1" in
7168 api) echo apihost ;;
72 chat) echo im ;;
73 sync) echo synchost ;;
74 hook) echo webhook ;;
7569 ui) echo web ;;
7670 *) echo "$1" ;;
7771 esac
@@ -80,9 +74,6 @@
8074url_of() {
8175 case "$1" in
8276 apihost) echo "http://127.0.0.1:4081 (Swagger: /api/docs)" ;;
83 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" ;;
8677 web) echo "http://127.0.0.1:5173" ;;
8778 esac
8879}
@@ -254,7 +245,7 @@
254245 fi
255246 printf ' %-12s %s\n' 'os' "$(uname -s)"
256247
257 info '== 依赖服务(PostgreSQL / Redis / S3 / Meilisearch)=='
248 info '== 依赖服务(PostgreSQL / Redis / S3)=='
258249 for pair in $INFRA; do
259250 name="${pair%%:*}"; port="${pair##*:}"
260251 if port_open "$port"; then
@@ -311,7 +302,7 @@
311302 info ' 二选一:'
312303 info ' macOS: brew install --cask docker (或 colima: brew install colima && colima start)'
313304 info ' Linux: 安装 docker engine + compose plugin'
314 info ' 也可自行启动 PostgreSQL/Redis/S3/Meilisearch,或用 APP_* 环境变量指向已有实例'
305 info ' 也可自行启动 PostgreSQL/Redis/S3,或用 APP_* 环境变量指向已有实例'
315306 return 1
316307 fi
317308 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']>
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,
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 (
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]
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
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}
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
@@ -178,10 +178,11 @@
178178 "auth",
179179 "axum",
180180 "chat",
181 "config",
181 "config 1.0.0",
182182 "export",
183183 "jwt",
184184 "push",
185 "queue",
185186 "tokio",
186187 "tokio-util",
187188 "tracing",
@@ -228,6 +229,12 @@
228229]
229230
230231[[package]]
232name = "arraydeque"
233version = "0.5.1"
234source = "registry+https://github.com/rust-lang/crates.io-index"
235checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
236
237[[package]]
231238name = "arrayvec"
232239version = "0.7.8"
233240source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -496,7 +503,7 @@
496503 "async-trait",
497504 "axum",
498505 "base64 0.23.1",
499 "config",
506 "config 1.0.0",
500507 "export",
501508 "hkdf 0.13.0",
502509 "hmac 0.13.0",
@@ -535,10 +542,10 @@
535542checksum = "01eb8a458c003bd8fe37badba2d8a5a3839748b6ade5cc2d92c2c6b9c6d5a437"
536543dependencies = [
537544 "attohttpc",
538 "dirs",
545 "dirs 5.0.1",
539546 "log 0.4.33",
540547 "quick-xml",
541 "rust-ini",
548 "rust-ini 0.18.0",
542549 "serde",
543550 "thiserror 1.0.69",
544551 "time 0.3.55",
@@ -968,7 +975,7 @@
968975 "axum",
969976 "base64 0.23.1",
970977 "bytes",
971 "config",
978 "config 1.0.0",
972979 "encoding_rs",
973980 "export",
974981 "futures",
@@ -981,11 +988,11 @@
981988 "livekit-api",
982989 "livekit-protocol",
983990 "models",
991 "queue",
984992 "rand 0.8.7",
985993 "redis",
986994 "reqwest 0.12.28",
987995 "sea-orm",
988 "search",
989996 "serde",
990997 "serde_json",
991998 "sha2 0.11.0",
@@ -1002,6 +1009,17 @@
10021009]
10031010
10041011[[package]]
1012name = "cheetah-string"
1013version = "1.2.0"
1014source = "registry+https://github.com/rust-lang/crates.io-index"
1015checksum = "a0e88622a110f2e13dd967659b323da9481172a1092d8b1c9a45194f2a36b758"
1016dependencies = [
1017 "bytes",
1018 "memchr",
1019 "serde",
1020]
1021
1022[[package]]
10051023name = "chrono"
10061024version = "0.4.45"
10071025source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1016,51 +1034,13 @@
10161034]
10171035
10181036[[package]]
1019name = "ci"
1020version = "1.0.0"
1037name = "chrono-tz"
1038version = "0.10.4"
1039source = "registry+https://github.com/rust-lang/crates.io-index"
1040checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
10211041dependencies = [
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",
1042 "chrono",
1043 "phf 0.12.1",
10641044]
10651045
10661046[[package]]
@@ -1159,12 +1139,31 @@
11591139
11601140[[package]]
11611141name = "config"
1142version = "0.15.27"
1143source = "registry+https://github.com/rust-lang/crates.io-index"
1144checksum = "38e9fbc53afcec54441422f1c596cd054655b32e70e1cbac956651b7b175a404"
1145dependencies = [
1146 "async-trait",
1147 "convert_case",
1148 "json5",
1149 "pathdiff",
1150 "ron",
1151 "rust-ini 0.21.3",
1152 "serde-untagged",
1153 "serde_core",
1154 "serde_json",
1155 "toml 1.1.8+spec-1.1.0",
1156 "winnow 1.0.4",
1157 "yaml-rust2",
1158]
1159
1160[[package]]
1161name = "config"
11621162version = "1.0.0"
11631163dependencies = [
11641164 "base64 0.23.1",
11651165 "dotenvy",
11661166 "figment",
1167 "ipnet",
11681167 "sea-orm",
11691168 "serde",
11701169 "thiserror 2.0.20",
@@ -1211,6 +1210,25 @@
12111210checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
12121211
12131212[[package]]
1213name = "convert_case"
1214version = "0.6.0"
1215source = "registry+https://github.com/rust-lang/crates.io-index"
1216checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
1217dependencies = [
1218 "unicode-segmentation",
1219]
1220
1221[[package]]
1222name = "core-foundation"
1223version = "0.9.4"
1224source = "registry+https://github.com/rust-lang/crates.io-index"
1225checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
1226dependencies = [
1227 "core-foundation-sys",
1228 "libc",
1229]
1230
1231[[package]]
12141232name = "core-foundation"
12151233version = "0.10.1"
12161234source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1281,6 +1299,36 @@
12811299]
12821300
12831301[[package]]
1302name = "cron"
1303version = "0.16.0"
1304source = "registry+https://github.com/rust-lang/crates.io-index"
1305checksum = "089df96cf6a25253b4b6b6744d86f91150a3d4df546f31a95def47976b8cba97"
1306dependencies = [
1307 "chrono",
1308 "once_cell",
1309 "phf 0.11.3",
1310 "winnow 0.7.15",
1311]
1312
1313[[package]]
1314name = "crossbeam-channel"
1315version = "0.5.17"
1316source = "registry+https://github.com/rust-lang/crates.io-index"
1317checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
1318dependencies = [
1319 "crossbeam-utils",
1320]
1321
1322[[package]]
1323name = "crossbeam-epoch"
1324version = "0.9.21"
1325source = "registry+https://github.com/rust-lang/crates.io-index"
1326checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
1327dependencies = [
1328 "crossbeam-utils",
1329]
1330
1331[[package]]
12841332name = "crossbeam-queue"
12851333version = "0.3.13"
12861334source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1388,6 +1436,7 @@
13881436 "ident_case",
13891437 "proc-macro2",
13901438 "quote",
1439 "strsim",
13911440 "syn 2.0.119",
13921441]
13931442
@@ -1403,6 +1452,21 @@
14031452]
14041453
14051454[[package]]
1455name = "dashmap"
1456version = "6.2.1"
1457source = "registry+https://github.com/rust-lang/crates.io-index"
1458checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
1459dependencies = [
1460 "cfg-if",
1461 "crossbeam-utils",
1462 "hashbrown 0.14.5",
1463 "lock_api",
1464 "once_cell",
1465 "parking_lot_core",
1466 "serde",
1467]
1468
1469[[package]]
14061470name = "data-encoding"
14071471version = "2.11.1"
14081472source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1461,6 +1525,37 @@
14611525]
14621526
14631527[[package]]
1528name = "derive_builder"
1529version = "0.20.2"
1530source = "registry+https://github.com/rust-lang/crates.io-index"
1531checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
1532dependencies = [
1533 "derive_builder_macro",
1534]
1535
1536[[package]]
1537name = "derive_builder_core"
1538version = "0.20.2"
1539source = "registry+https://github.com/rust-lang/crates.io-index"
1540checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
1541dependencies = [
1542 "darling",
1543 "proc-macro2",
1544 "quote",
1545 "syn 2.0.119",
1546]
1547
1548[[package]]
1549name = "derive_builder_macro"
1550version = "0.20.2"
1551source = "registry+https://github.com/rust-lang/crates.io-index"
1552checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
1553dependencies = [
1554 "derive_builder_core",
1555 "syn 2.0.119",
1556]
1557
1558[[package]]
14641559name = "derive_more"
14651560version = "2.1.1"
14661561source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1488,7 +1583,7 @@
14881583source = "registry+https://github.com/rust-lang/crates.io-index"
14891584checksum = "a2ca8e71544c1b67dcdbc2699ab258828aff985e5bc8d5f6b486d90d7df2f848"
14901585dependencies = [
1491 "core-foundation",
1586 "core-foundation 0.10.1",
14921587 "jni",
14931588 "libc",
14941589 "thiserror 2.0.20",
@@ -1527,7 +1622,16 @@
15271622source = "registry+https://github.com/rust-lang/crates.io-index"
15281623checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
15291624dependencies = [
1530 "dirs-sys",
1625 "dirs-sys 0.4.1",
1626]
1627
1628[[package]]
1629name = "dirs"
1630version = "6.0.0"
1631source = "registry+https://github.com/rust-lang/crates.io-index"
1632checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
1633dependencies = [
1634 "dirs-sys 0.5.0",
15311635]
15321636
15331637[[package]]
@@ -1538,11 +1642,23 @@
15381642dependencies = [
15391643 "libc",
15401644 "option-ext",
1541 "redox_users",
1645 "redox_users 0.4.6",
15421646 "windows-sys 0.48.0",
15431647]
15441648
15451649[[package]]
1650name = "dirs-sys"
1651version = "0.5.0"
1652source = "registry+https://github.com/rust-lang/crates.io-index"
1653checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
1654dependencies = [
1655 "libc",
1656 "option-ext",
1657 "redox_users 0.5.3",
1658 "windows-sys 0.61.2",
1659]
1660
1661[[package]]
15461662name = "dispatch2"
15471663version = "0.3.1"
15481664source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1570,6 +1686,15 @@
15701686checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257"
15711687
15721688[[package]]
1689name = "dlv-list"
1690version = "0.5.2"
1691source = "registry+https://github.com/rust-lang/crates.io-index"
1692checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
1693dependencies = [
1694 "const-random",
1695]
1696
1697[[package]]
15731698name = "dotenvy"
15741699version = "0.15.7"
15751700source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1662,6 +1787,17 @@
16621787checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
16631788
16641789[[package]]
1790name = "erased-serde"
1791version = "0.4.11"
1792source = "registry+https://github.com/rust-lang/crates.io-index"
1793checksum = "1e59b668157f32d3459d4bd634861a06ffa2503a461ca5f0916b9f0252605e66"
1794dependencies = [
1795 "serde",
1796 "serde_core",
1797 "typeid",
1798]
1799
1800[[package]]
16651801name = "errno"
16661802version = "0.3.14"
16671803source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1717,14 +1853,16 @@
17171853 "axum",
17181854 "axum-otel-metrics",
17191855 "bytes",
1720 "config",
1856 "config 1.0.0",
17211857 "http-body-util",
17221858 "imaging",
17231859 "kenauth",
1860 "models",
17241861 "opentelemetry",
17251862 "opentelemetry-http",
17261863 "opentelemetry-otlp",
17271864 "opentelemetry_sdk",
1865 "queue",
17281866 "redis",
17291867 "reqwest 0.12.28",
17301868 "sea-orm",
@@ -1749,6 +1887,9 @@
17491887version = "2.5.0"
17501888source = "registry+https://github.com/rust-lang/crates.io-index"
17511889checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
1890dependencies = [
1891 "getrandom 0.4.3",
1892]
17521893
17531894[[package]]
17541895name = "fdeflate"
@@ -1784,7 +1925,7 @@
17841925 "atomic",
17851926 "pear",
17861927 "serde",
1787 "toml",
1928 "toml 0.8.23",
17881929 "uncased",
17891930 "version_check 0.9.5",
17901931]
@@ -1808,6 +1949,7 @@
18081949checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
18091950dependencies = [
18101951 "crc32fast",
1952 "libz-sys",
18111953 "miniz_oxide",
18121954 "zlib-rs",
18131955]
@@ -1818,6 +1960,7 @@
18181960source = "registry+https://github.com/rust-lang/crates.io-index"
18191961checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
18201962dependencies = [
1963 "fastrand",
18211964 "futures-core",
18221965 "futures-sink",
18231966 "spin",
@@ -2030,6 +2173,17 @@
20302173]
20312174
20322175[[package]]
2176name = "getset"
2177version = "0.1.7"
2178source = "registry+https://github.com/rust-lang/crates.io-index"
2179checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77"
2180dependencies = [
2181 "proc-macro2",
2182 "quote",
2183 "syn 2.0.119",
2184]
2185
2186[[package]]
20332187name = "ghash"
20342188version = "0.5.1"
20352189source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2067,8 +2221,7 @@
20672221 "base64 0.23.1",
20682222 "bytes",
20692223 "cache",
2070 "ci-protocol",
2071 "config",
2224 "config 1.0.0",
20722225 "export",
20732226 "flate2",
20742227 "futures",
@@ -2081,10 +2234,8 @@
20812234 "models",
20822235 "prost-types 0.14.1",
20832236 "pulldown-cmark",
2084 "rand 0.8.7",
20852237 "redis",
20862238 "sea-orm",
2087 "search",
20882239 "serde",
20892240 "serde_json",
20902241 "sha2 0.11.0",
@@ -2210,6 +2361,18 @@
22102361
22112362[[package]]
22122363name = "hashbrown"
2364version = "0.14.5"
2365source = "registry+https://github.com/rust-lang/crates.io-index"
2366checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
2367
2368[[package]]
2369name = "hashbrown"
2370version = "0.15.5"
2371source = "registry+https://github.com/rust-lang/crates.io-index"
2372checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
2373
2374[[package]]
2375name = "hashbrown"
22132376version = "0.16.1"
22142377source = "registry+https://github.com/rust-lang/crates.io-index"
22152378checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
@@ -2278,7 +2441,7 @@
22782441 "hayro-syntax",
22792442 "kurbo",
22802443 "moxcms",
2281 "phf",
2444 "phf 0.13.1",
22822445 "rustc-hash",
22832446 "siphasher",
22842447 "skrifa",
@@ -2412,6 +2575,17 @@
24122575]
24132576
24142577[[package]]
2578name = "hostname"
2579version = "0.4.2"
2580source = "registry+https://github.com/rust-lang/crates.io-index"
2581checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
2582dependencies = [
2583 "cfg-if",
2584 "libc",
2585 "windows-link",
2586]
2587
2588[[package]]
24152589name = "http"
24162590version = "0.2.12"
24172591source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2624,16 +2798,17 @@
26242798 "percent-encoding 2.3.2",
26252799 "pin-project-lite",
26262800 "socket2 0.6.5",
2801 "system-configuration",
26272802 "tokio",
26282803 "tower-service",
26292804 "tracing",
2805 "windows-registry",
26302806]
26312807
26322808[[package]]
26332809name = "i18n"
26342810version = "1.0.0"
26352811dependencies = [
2636 "ci-protocol",
26372812 "models",
26382813 "sea-orm",
26392814 "uuid",
@@ -2785,33 +2960,6 @@
27852960]
27862961
27872962[[package]]
2788name = "im"
2789version = "1.0.0"
2790dependencies = [
2791 "anyhow",
2792 "bytes",
2793 "chat",
2794 "config",
2795 "export",
2796 "futures",
2797 "http 1.5.0",
2798 "http-body-util",
2799 "hyper 1.11.0",
2800 "hyper-util",
2801 "jwt",
2802 "redis",
2803 "sea-orm",
2804 "serde",
2805 "serde_json",
2806 "time 0.3.55",
2807 "tokio",
2808 "tokio-tungstenite",
2809 "tokio-util",
2810 "tracing",
2811 "uuid",
2812]
2813
2814[[package]]
28152963name = "image"
28162964version = "0.25.6"
28172965source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2847,33 +2995,6 @@
28472995 "serde_json",
28482996 "tokio",
28492997 "utoipa",
2850]
2851
2852[[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",
28772998]
28782999
28793000[[package]]
@@ -2928,20 +3049,26 @@
29283049checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78"
29293050
29303051[[package]]
3052name = "ipnetwork"
3053version = "0.21.1"
3054source = "registry+https://github.com/rust-lang/crates.io-index"
3055checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763"
3056
3057[[package]]
29313058name = "issues"
29323059version = "1.0.0"
29333060dependencies = [
29343061 "async-trait",
29353062 "axum",
2936 "config",
3063 "config 1.0.0",
29373064 "export",
29383065 "http 0.2.12",
29393066 "i18n",
29403067 "jwt",
29413068 "kenauth",
29423069 "models",
3070 "queue",
29433071 "sea-orm",
2944 "search",
29453072 "serde",
29463073 "serde_json",
29473074 "space",
@@ -3050,6 +3177,16 @@
30503177]
30513178
30523179[[package]]
3180name = "json5"
3181version = "1.3.6"
3182source = "registry+https://github.com/rust-lang/crates.io-index"
3183checksum = "e46128c9fb848c29097da3149e627cce5496d0b2820a9be06d12ee266c09c749"
3184dependencies = [
3185 "serde",
3186 "ucd-trie",
3187]
3188
3189[[package]]
30533190name = "jsonwebtoken"
30543191version = "9.3.1"
30553192source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3085,7 +3222,7 @@
30853222dependencies = [
30863223 "axum",
30873224 "base64 0.23.1",
3088 "config",
3225 "config 1.0.0",
30893226 "jsonwebtoken 9.3.1",
30903227 "reqwest 0.12.28",
30913228 "serde",
@@ -3150,7 +3287,7 @@
31503287name = "kenauth"
31513288version = "1.0.0"
31523289dependencies = [
3153 "config",
3290 "config 1.0.0",
31543291 "prost 0.14.4",
31553292 "prost-types 0.14.1",
31563293 "thiserror 2.0.20",
@@ -3294,8 +3431,19 @@
32943431]
32953432
32963433[[package]]
3297name = "linebender_resource_handle"
3298version = "0.1.1"
3434name = "libz-sys"
3435version = "1.1.31"
3436source = "registry+https://github.com/rust-lang/crates.io-index"
3437checksum = "ff20308ecabd635a2dc1dd5834a86c462cca20efba1d5d3ddab1f8aea3455cb2"
3438dependencies = [
3439 "cc",
3440 "pkg-config",
3441 "vcpkg",
3442]
3443
3444[[package]]
3445name = "linebender_resource_handle"
3446version = "0.1.1"
32993447source = "registry+https://github.com/rust-lang/crates.io-index"
33003448checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
33013449
@@ -3420,6 +3568,17 @@
34203568]
34213569
34223570[[package]]
3571name = "local-ip-address"
3572version = "0.6.13"
3573source = "registry+https://github.com/rust-lang/crates.io-index"
3574checksum = "aa08fb2b1ec3ea84575e94b489d06d4ce0cbf052d12acd515838f50e3c3d63e3"
3575dependencies = [
3576 "libc",
3577 "neli",
3578 "windows-sys 0.61.2",
3579]
3580
3581[[package]]
34233582name = "lock_api"
34243583version = "0.4.14"
34253584source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3450,6 +3609,12 @@
34503609checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
34513610
34523611[[package]]
3612name = "lz4_flex"
3613version = "0.13.1"
3614source = "registry+https://github.com/rust-lang/crates.io-index"
3615checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e"
3616
3617[[package]]
34533618name = "mac_address"
34543619version = "1.1.8"
34553620source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3507,6 +3672,16 @@
35073672
35083673[[package]]
35093674name = "md-5"
3675version = "0.10.6"
3676source = "registry+https://github.com/rust-lang/crates.io-index"
3677checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
3678dependencies = [
3679 "cfg-if",
3680 "digest 0.10.7",
3681]
3682
3683[[package]]
3684name = "md-5"
35103685version = "0.11.0"
35113686source = "registry+https://github.com/rust-lang/crates.io-index"
35123687checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
@@ -3541,7 +3716,7 @@
35413716version = "1.0.0"
35423717dependencies = [
35433718 "anyhow",
3544 "config",
3719 "config 1.0.0",
35453720 "models",
35463721 "sea-orm",
35473722 "tokio",
@@ -3645,6 +3820,23 @@
36453820]
36463821
36473822[[package]]
3823name = "moka"
3824version = "0.12.16"
3825source = "registry+https://github.com/rust-lang/crates.io-index"
3826checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9"
3827dependencies = [
3828 "crossbeam-channel",
3829 "crossbeam-epoch",
3830 "crossbeam-utils",
3831 "equivalent",
3832 "parking_lot",
3833 "portable-atomic",
3834 "smallvec",
3835 "tagptr",
3836 "uuid",
3837]
3838
3839[[package]]
36483840name = "moxcms"
36493841version = "0.8.1"
36503842source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3678,6 +3870,36 @@
36783870]
36793871
36803872[[package]]
3873name = "neli"
3874version = "0.7.5"
3875source = "registry+https://github.com/rust-lang/crates.io-index"
3876checksum = "6b152f4f18819e638ea350a740b16de75099302b5ec3a58f4f8766ef2fcf695d"
3877dependencies = [
3878 "bitflags 2.13.1",
3879 "byteorder",
3880 "derive_builder",
3881 "getset",
3882 "libc",
3883 "linux-raw-sys",
3884 "log 0.4.33",
3885 "neli-proc-macros",
3886 "parking_lot",
3887]
3888
3889[[package]]
3890name = "neli-proc-macros"
3891version = "0.2.2"
3892source = "registry+https://github.com/rust-lang/crates.io-index"
3893checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609"
3894dependencies = [
3895 "either",
3896 "proc-macro2",
3897 "quote",
3898 "serde",
3899 "syn 2.0.119",
3900]
3901
3902[[package]]
36813903name = "nix"
36823904version = "0.29.0"
36833905source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4122,11 +4344,21 @@
41224344source = "registry+https://github.com/rust-lang/crates.io-index"
41234345checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a"
41244346dependencies = [
4125 "dlv-list",
4347 "dlv-list 0.3.0",
41264348 "hashbrown 0.12.3",
41274349]
41284350
41294351[[package]]
4352name = "ordered-multimap"
4353version = "0.7.3"
4354source = "registry+https://github.com/rust-lang/crates.io-index"
4355checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
4356dependencies = [
4357 "dlv-list 0.5.2",
4358 "hashbrown 0.14.5",
4359]
4360
4361[[package]]
41304362name = "os_info"
41314363version = "3.15.0"
41324364source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4230,6 +4462,12 @@
42304462]
42314463
42324464[[package]]
4465name = "pathdiff"
4466version = "0.2.3"
4467source = "registry+https://github.com/rust-lang/crates.io-index"
4468checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
4469
4470[[package]]
42334471name = "pbjson"
42344472version = "0.6.0"
42354473source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4365,12 +4603,41 @@
43654603
43664604[[package]]
43674605name = "phf"
4606version = "0.11.3"
4607source = "registry+https://github.com/rust-lang/crates.io-index"
4608checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
4609dependencies = [
4610 "phf_macros 0.11.3",
4611 "phf_shared 0.11.3",
4612]
4613
4614[[package]]
4615name = "phf"
4616version = "0.12.1"
4617source = "registry+https://github.com/rust-lang/crates.io-index"
4618checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
4619dependencies = [
4620 "phf_shared 0.12.1",
4621]
4622
4623[[package]]
4624name = "phf"
43684625version = "0.13.1"
43694626source = "registry+https://github.com/rust-lang/crates.io-index"
43704627checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
43714628dependencies = [
4372 "phf_macros",
4373 "phf_shared",
4629 "phf_macros 0.13.1",
4630 "phf_shared 0.13.1",
4631]
4632
4633[[package]]
4634name = "phf_generator"
4635version = "0.11.3"
4636source = "registry+https://github.com/rust-lang/crates.io-index"
4637checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
4638dependencies = [
4639 "phf_shared 0.11.3",
4640 "rand 0.8.7",
43744641]
43754642
43764643[[package]]
@@ -4380,7 +4647,20 @@
43804647checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
43814648dependencies = [
43824649 "fastrand",
4383 "phf_shared",
4650 "phf_shared 0.13.1",
4651]
4652
4653[[package]]
4654name = "phf_macros"
4655version = "0.11.3"
4656source = "registry+https://github.com/rust-lang/crates.io-index"
4657checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
4658dependencies = [
4659 "phf_generator 0.11.3",
4660 "phf_shared 0.11.3",
4661 "proc-macro2",
4662 "quote",
4663 "syn 2.0.119",
43844664]
43854665
43864666[[package]]
@@ -4389,8 +4669,8 @@
43894669source = "registry+https://github.com/rust-lang/crates.io-index"
43904670checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
43914671dependencies = [
4392 "phf_generator",
4393 "phf_shared",
4672 "phf_generator 0.13.1",
4673 "phf_shared 0.13.1",
43944674 "proc-macro2",
43954675 "quote",
43964676 "syn 2.0.119",
@@ -4398,6 +4678,24 @@
43984678
43994679[[package]]
44004680name = "phf_shared"
4681version = "0.11.3"
4682source = "registry+https://github.com/rust-lang/crates.io-index"
4683checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
4684dependencies = [
4685 "siphasher",
4686]
4687
4688[[package]]
4689name = "phf_shared"
4690version = "0.12.1"
4691source = "registry+https://github.com/rust-lang/crates.io-index"
4692checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
4693dependencies = [
4694 "siphasher",
4695]
4696
4697[[package]]
4698name = "phf_shared"
44014699version = "0.13.1"
44024700source = "registry+https://github.com/rust-lang/crates.io-index"
44034701checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
@@ -4785,17 +5083,15 @@
47855083version = "1.0.0"
47865084dependencies = [
47875085 "axum",
4788 "ci",
4789 "ci-protocol",
4790 "config",
5086 "config 1.0.0",
47915087 "export",
47925088 "git",
47935089 "globset",
47945090 "issues",
47955091 "jwt",
47965092 "models",
5093 "queue",
47975094 "sea-orm",
4798 "search",
47995095 "serde",
48005096 "serde_json",
48015097 "space",
@@ -4810,7 +5106,7 @@
48105106version = "1.0.0"
48115107dependencies = [
48125108 "base64 0.23.1",
4813 "config",
5109 "config 1.0.0",
48145110 "export",
48155111 "http 1.5.0",
48165112 "models",
@@ -4832,6 +5128,24 @@
48325128checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
48335129
48345130[[package]]
5131name = "queue"
5132version = "1.0.0"
5133dependencies = [
5134 "bytes",
5135 "config 1.0.0",
5136 "rocketmq-client-rust",
5137 "rocketmq-common",
5138 "sea-orm",
5139 "serde",
5140 "serde_json",
5141 "time 0.3.55",
5142 "tokio",
5143 "tokio-util",
5144 "tracing",
5145 "uuid",
5146]
5147
5148[[package]]
48355149name = "quick-error"
48365150version = "2.0.1"
48375151source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5017,6 +5331,19 @@
50175331]
50185332
50195333[[package]]
5334name = "rcgen"
5335version = "0.13.2"
5336source = "registry+https://github.com/rust-lang/crates.io-index"
5337checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
5338dependencies = [
5339 "pem",
5340 "ring",
5341 "rustls-pki-types",
5342 "time 0.3.55",
5343 "yasna",
5344]
5345
5346[[package]]
50205347name = "read-fonts"
50215348version = "0.39.2"
50225349source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5078,6 +5405,16 @@
50785405]
50795406
50805407[[package]]
5408name = "redox_users"
5409version = "0.5.3"
5410source = "registry+https://github.com/rust-lang/crates.io-index"
5411checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0"
5412dependencies = [
5413 "libredox",
5414 "thiserror 2.0.20",
5415]
5416
5417[[package]]
50815418name = "regex"
50825419version = "1.13.1"
50835420source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5161,9 +5498,11 @@
51615498dependencies = [
51625499 "base64 0.22.1",
51635500 "bytes",
5501 "encoding_rs",
51645502 "futures-channel",
51655503 "futures-core",
51665504 "futures-util",
5505 "h2 0.4.19",
51675506 "http 1.5.0",
51685507 "http-body 1.1.0",
51695508 "http-body-util",
@@ -5172,12 +5511,15 @@
51725511 "hyper-util",
51735512 "js-sys",
51745513 "log 0.4.33",
5514 "mime 0.3.17",
51755515 "percent-encoding 2.3.2",
51765516 "pin-project-lite",
51775517 "quinn",
51785518 "rustls",
51795519 "rustls-pki-types",
51805520 "rustls-platform-verifier",
5521 "serde",
5522 "serde_json",
51815523 "sync_wrapper",
51825524 "tokio",
51835525 "tokio-rustls",
@@ -5244,6 +5586,208 @@
52445586]
52455587
52465588[[package]]
5589name = "rocketmq-auth"
5590version = "0.9.0"
5591source = "registry+https://github.com/rust-lang/crates.io-index"
5592checksum = "4e0584102ce3b4f0286e6e1f80466841f6e55365a22f5cc5232370a9dbb112ca"
5593dependencies = [
5594 "base64 0.22.1",
5595 "cheetah-string",
5596 "chrono",
5597 "hex",
5598 "hmac 0.13.0",
5599 "ipnetwork",
5600 "md-5 0.11.0",
5601 "moka",
5602 "rocketmq-common",
5603 "rocketmq-error",
5604 "rocketmq-remoting",
5605 "serde",
5606 "serde_json",
5607 "serde_yaml",
5608 "sha1 0.11.0",
5609 "sha2 0.11.0",
5610 "thiserror 2.0.20",
5611 "tokio",
5612 "tracing",
5613]
5614
5615[[package]]
5616name = "rocketmq-client-rust"
5617version = "0.9.0"
5618source = "registry+https://github.com/rust-lang/crates.io-index"
5619checksum = "48fb082c2b5c1f32ee0156b21a633b82630c6ecb0a4cb755b7897a1a735ff9d6"
5620dependencies = [
5621 "bytes",
5622 "cheetah-string",
5623 "dashmap",
5624 "futures",
5625 "num_cpus",
5626 "parking_lot",
5627 "rand 0.10.2",
5628 "rocketmq-auth",
5629 "rocketmq-common",
5630 "rocketmq-error",
5631 "rocketmq-remoting",
5632 "rocketmq-runtime",
5633 "rocketmq-rust",
5634 "serde",
5635 "serde_json",
5636 "smallvec",
5637 "tokio",
5638 "tokio-util",
5639 "tracing",
5640 "trait-variant",
5641]
5642
5643[[package]]
5644name = "rocketmq-common"
5645version = "0.9.0"
5646source = "registry+https://github.com/rust-lang/crates.io-index"
5647checksum = "d7f82de53d643f6c88f11fb4e5803063e20de7a9aa4eb5b9fee2a3963a4f5a2c"
5648dependencies = [
5649 "anyhow",
5650 "base64 0.22.1",
5651 "byteorder",
5652 "bytes",
5653 "cheetah-string",
5654 "chrono",
5655 "chrono-tz",
5656 "config 0.15.27",
5657 "crc",
5658 "dashmap",
5659 "dirs 6.0.0",
5660 "flate2",
5661 "form_urlencoded",
5662 "futures",
5663 "hostname",
5664 "iana-time-zone",
5665 "local-ip-address",
5666 "lz4_flex",
5667 "md-5 0.10.6",
5668 "num_cpus",
5669 "opentelemetry",
5670 "parking_lot",
5671 "regex",
5672 "reqwest 0.13.4",
5673 "rocketmq-error",
5674 "rocketmq-rust",
5675 "serde",
5676 "serde_json",
5677 "tempfile",
5678 "thiserror 2.0.20",
5679 "time 0.3.55",
5680 "tokio",
5681 "tracing",
5682 "tracing-appender",
5683 "tracing-subscriber",
5684 "trait-variant",
5685 "uuid",
5686 "zstd",
5687]
5688
5689[[package]]
5690name = "rocketmq-error"
5691version = "0.9.0"
5692source = "registry+https://github.com/rust-lang/crates.io-index"
5693checksum = "38fade7f274ff354923e0d453e53e61f1fcf7271dfdf7bdee86760579c561711"
5694dependencies = [
5695 "anyhow",
5696 "config 0.15.27",
5697 "serde_json",
5698 "thiserror 2.0.20",
5699]
5700
5701[[package]]
5702name = "rocketmq-macros"
5703version = "0.9.0"
5704source = "registry+https://github.com/rust-lang/crates.io-index"
5705checksum = "74f9ab7ab4f2096ed8ab9f15be489c9be963ada4fffc0700222a61a9777f250a"
5706dependencies = [
5707 "proc-macro2",
5708 "quote",
5709 "syn 2.0.119",
5710]
5711
5712[[package]]
5713name = "rocketmq-remoting"
5714version = "0.9.0"
5715source = "registry+https://github.com/rust-lang/crates.io-index"
5716checksum = "3b609e95e018894c42c90654ea8d9b9be20d2566760ecbfa54b132c64a43bcb9"
5717dependencies = [
5718 "anyhow",
5719 "arc-swap",
5720 "bitvec",
5721 "bytemuck",
5722 "bytes",
5723 "cheetah-string",
5724 "dashmap",
5725 "flate2",
5726 "flume",
5727 "futures",
5728 "futures-util",
5729 "num_cpus",
5730 "parking_lot",
5731 "rand 0.10.2",
5732 "rcgen",
5733 "rocketmq-common",
5734 "rocketmq-error",
5735 "rocketmq-macros",
5736 "rocketmq-rust",
5737 "rustls-native-certs",
5738 "rustls-pemfile",
5739 "serde",
5740 "serde_json",
5741 "serde_json_any_key",
5742 "tokio",
5743 "tokio-rustls",
5744 "tokio-util",
5745 "tracing",
5746 "trait-variant",
5747 "uuid",
5748]
5749
5750[[package]]
5751name = "rocketmq-runtime"
5752version = "0.9.0"
5753source = "registry+https://github.com/rust-lang/crates.io-index"
5754checksum = "77fc99d6c519fbc6398571920810cda636d134386232d2ec721dd720760d0b6a"
5755dependencies = [
5756 "tokio",
5757]
5758
5759[[package]]
5760name = "rocketmq-rust"
5761version = "0.9.0"
5762source = "registry+https://github.com/rust-lang/crates.io-index"
5763checksum = "b610d847664dabce9ac7c41999f39573532c460af2eda3cdc517c8d09ca96c52"
5764dependencies = [
5765 "anyhow",
5766 "chrono",
5767 "cron",
5768 "parking_lot",
5769 "rocketmq-error",
5770 "serde",
5771 "tokio",
5772 "tokio-util",
5773 "tracing",
5774 "uuid",
5775]
5776
5777[[package]]
5778name = "ron"
5779version = "0.12.3"
5780source = "registry+https://github.com/rust-lang/crates.io-index"
5781checksum = "97b3fe97c18ff24299ea56f802c5693eb34266fb0bc8317f4e101debc8a3ceaf"
5782dependencies = [
5783 "bitflags 2.13.1",
5784 "serde",
5785 "serde_derive",
5786 "typeid",
5787 "unicode-ident",
5788]
5789
5790[[package]]
52475791name = "rsa"
52485792version = "0.9.10"
52495793source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5262,29 +5806,6 @@
52625806 "spki 0.7.3",
52635807 "subtle",
52645808 "zeroize",
5265]
5266
5267[[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",
52885809]
52895810
52905811[[package]]
@@ -5329,7 +5850,17 @@
53295850checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df"
53305851dependencies = [
53315852 "cfg-if",
5332 "ordered-multimap",
5853 "ordered-multimap 0.4.3",
5854]
5855
5856[[package]]
5857name = "rust-ini"
5858version = "0.21.3"
5859source = "registry+https://github.com/rust-lang/crates.io-index"
5860checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
5861dependencies = [
5862 "cfg-if",
5863 "ordered-multimap 0.7.3",
53335864]
53345865
53355866[[package]]
@@ -5420,6 +5951,7 @@
54205951checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
54215952dependencies = [
54225953 "aws-lc-rs",
5954 "log 0.4.33",
54235955 "once_cell",
54245956 "ring",
54255957 "rustls-pki-types",
@@ -5441,6 +5973,15 @@
54415973]
54425974
54435975[[package]]
5976name = "rustls-pemfile"
5977version = "2.2.0"
5978source = "registry+https://github.com/rust-lang/crates.io-index"
5979checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
5980dependencies = [
5981 "rustls-pki-types",
5982]
5983
5984[[package]]
54445985name = "rustls-pki-types"
54455986version = "1.15.1"
54465987source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5456,7 +5997,7 @@
54565997source = "registry+https://github.com/rust-lang/crates.io-index"
54575998checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
54585999dependencies = [
5459 "core-foundation",
6000 "core-foundation 0.10.1",
54606001 "core-foundation-sys",
54616002 "jni",
54626003 "log 0.4.33",
@@ -5690,52 +6231,10 @@
56906231]
56916232
56926233[[package]]
5693name = "seahash"
5694version = "4.1.0"
5695source = "registry+https://github.com/rust-lang/crates.io-index"
5696checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
5697
5698[[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]
6234name = "seahash"
6235version = "4.1.0"
6236source = "registry+https://github.com/rust-lang/crates.io-index"
6237checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
57396238
57406239[[package]]
57416240name = "sec1"
@@ -5758,7 +6257,7 @@
57586257checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
57596258dependencies = [
57606259 "bitflags 2.13.1",
5761 "core-foundation",
6260 "core-foundation 0.10.1",
57626261 "core-foundation-sys",
57636262 "libc",
57646263 "security-framework-sys",
@@ -5791,6 +6290,18 @@
57916290]
57926291
57936292[[package]]
6293name = "serde-untagged"
6294version = "0.1.9"
6295source = "registry+https://github.com/rust-lang/crates.io-index"
6296checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058"
6297dependencies = [
6298 "erased-serde",
6299 "serde",
6300 "serde_core",
6301 "typeid",
6302]
6303
6304[[package]]
57946305name = "serde_core"
57956306version = "1.0.229"
57966307source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5816,7 +6327,6 @@
58166327source = "registry+https://github.com/rust-lang/crates.io-index"
58176328checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
58186329dependencies = [
5819 "indexmap",
58206330 "itoa",
58216331 "memchr",
58226332 "serde",
@@ -5825,6 +6335,16 @@
58256335]
58266336
58276337[[package]]
6338name = "serde_json_any_key"
6339version = "2.1.0"
6340source = "registry+https://github.com/rust-lang/crates.io-index"
6341checksum = "b82e4b1d3a2cb67817b8c403eac7b98225c2e1c855a13ef9b80b9bac63a06c20"
6342dependencies = [
6343 "serde",
6344 "serde_json",
6345]
6346
6347[[package]]
58286348name = "serde_path_to_error"
58296349version = "0.1.20"
58306350source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5845,6 +6365,15 @@
58456365]
58466366
58476367[[package]]
6368name = "serde_spanned"
6369version = "1.1.2"
6370source = "registry+https://github.com/rust-lang/crates.io-index"
6371checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
6372dependencies = [
6373 "serde_core",
6374]
6375
6376[[package]]
58486377name = "serde_urlencoded"
58496378version = "0.7.1"
58506379source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5857,10 +6386,10 @@
58576386]
58586387
58596388[[package]]
5860name = "serde_yaml_ng"
5861version = "0.10.0"
6389name = "serde_yaml"
6390version = "0.9.34+deprecated"
58626391source = "registry+https://github.com/rust-lang/crates.io-index"
5863checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f"
6392checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
58646393dependencies = [
58656394 "indexmap",
58666395 "itoa",
@@ -6079,7 +6608,7 @@
60796608dependencies = [
60806609 "async-trait",
60816610 "axum",
6082 "config",
6611 "config 1.0.0",
60836612 "export",
60846613 "i18n",
60856614 "imaging",
@@ -6087,9 +6616,7 @@
60876616 "kenauth",
60886617 "models",
60896618 "prost-types 0.14.1",
6090 "rand 0.8.7",
60916619 "sea-orm",
6092 "search",
60936620 "serde",
60946621 "serde_json",
60956622 "storage",
@@ -6277,7 +6804,7 @@
62776804 "hmac 0.13.0",
62786805 "itoa",
62796806 "log 0.4.33",
6280 "md-5",
6807 "md-5 0.11.0",
62816808 "memchr",
62826809 "rand 0.10.2",
62836810 "rust_decimal",
@@ -6338,19 +6865,13 @@
63386865version = "1.0.0"
63396866dependencies = [
63406867 "bytes",
6341 "config",
6868 "config 1.0.0",
63426869 "futures",
63436870 "http 0.2.12",
63446871 "rust-s3-async",
63456872 "tokio",
63466873 "tracing",
63476874]
6348
6349[[package]]
6350name = "streaming-iterator"
6351version = "0.1.9"
6352source = "registry+https://github.com/rust-lang/crates.io-index"
6353checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
63546875
63556876[[package]]
63566877name = "stringprep"
@@ -6364,6 +6885,12 @@
63646885]
63656886
63666887[[package]]
6888name = "strsim"
6889version = "0.11.1"
6890source = "registry+https://github.com/rust-lang/crates.io-index"
6891checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
6892
6893[[package]]
63676894name = "strum"
63686895version = "0.28.0"
63696896source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6406,6 +6933,12 @@
64066933]
64076934
64086935[[package]]
6936name = "symlink"
6937version = "0.1.0"
6938source = "registry+https://github.com/rust-lang/crates.io-index"
6939checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
6940
6941[[package]]
64096942name = "syn"
64106943version = "1.0.109"
64116944source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6445,25 +6978,6 @@
64456978checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
64466979dependencies = [
64476980 "futures-core",
6448]
6449
6450[[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",
64676981]
64686982
64696983[[package]]
@@ -6478,6 +6992,33 @@
64786992]
64796993
64806994[[package]]
6995name = "system-configuration"
6996version = "0.7.0"
6997source = "registry+https://github.com/rust-lang/crates.io-index"
6998checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
6999dependencies = [
7000 "bitflags 2.13.1",
7001 "core-foundation 0.9.4",
7002 "system-configuration-sys",
7003]
7004
7005[[package]]
7006name = "system-configuration-sys"
7007version = "0.6.0"
7008source = "registry+https://github.com/rust-lang/crates.io-index"
7009checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
7010dependencies = [
7011 "core-foundation-sys",
7012 "libc",
7013]
7014
7015[[package]]
7016name = "tagptr"
7017version = "0.2.0"
7018source = "registry+https://github.com/rust-lang/crates.io-index"
7019checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
7020
7021[[package]]
64817022name = "tap"
64827023version = "1.0.1"
64837024source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6629,6 +7170,7 @@
66297170 "bytes",
66307171 "libc",
66317172 "mio",
7173 "parking_lot",
66327174 "pin-project-lite",
66337175 "signal-hook-registry",
66347176 "socket2 0.6.5",
@@ -6703,9 +7245,13 @@
67037245dependencies = [
67047246 "bytes",
67057247 "futures-core",
7248 "futures-io",
67067249 "futures-sink",
7250 "futures-util",
7251 "hashbrown 0.15.5",
67077252 "libc",
67087253 "pin-project-lite",
7254 "slab",
67097255 "tokio",
67107256]
67117257
@@ -6716,12 +7262,27 @@
67167262checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
67177263dependencies = [
67187264 "serde",
6719 "serde_spanned",
7265 "serde_spanned 0.6.9",
67207266 "toml_datetime 0.6.11",
67217267 "toml_edit 0.22.27",
67227268]
67237269
67247270[[package]]
7271name = "toml"
7272version = "1.1.8+spec-1.1.0"
7273source = "registry+https://github.com/rust-lang/crates.io-index"
7274checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
7275dependencies = [
7276 "indexmap",
7277 "serde_core",
7278 "serde_spanned 1.1.2",
7279 "toml_datetime 1.1.2+spec-1.1.0",
7280 "toml_parser",
7281 "toml_writer",
7282 "winnow 1.0.4",
7283]
7284
7285[[package]]
67257286name = "toml_datetime"
67267287version = "0.6.11"
67277288source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6732,9 +7293,9 @@
67327293
67337294[[package]]
67347295name = "toml_datetime"
6735version = "1.1.1+spec-1.1.0"
7296version = "1.1.2+spec-1.1.0"
67367297source = "registry+https://github.com/rust-lang/crates.io-index"
6737checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
7298checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
67387299dependencies = [
67397300 "serde_core",
67407301]
@@ -6747,7 +7308,7 @@
67477308dependencies = [
67487309 "indexmap",
67497310 "serde",
6750 "serde_spanned",
7311 "serde_spanned 0.6.9",
67517312 "toml_datetime 0.6.11",
67527313 "toml_write",
67537314 "winnow 0.7.15",
@@ -6760,16 +7321,16 @@
67607321checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
67617322dependencies = [
67627323 "indexmap",
6763 "toml_datetime 1.1.1+spec-1.1.0",
7324 "toml_datetime 1.1.2+spec-1.1.0",
67647325 "toml_parser",
67657326 "winnow 1.0.4",
67667327]
67677328
67687329[[package]]
67697330name = "toml_parser"
6770version = "1.1.3+spec-1.1.0"
7331version = "1.1.5+spec-1.1.0"
67717332source = "registry+https://github.com/rust-lang/crates.io-index"
6772checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
7333checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
67737334dependencies = [
67747335 "winnow 1.0.4",
67757336]
@@ -6781,6 +7342,12 @@
67817342checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
67827343
67837344[[package]]
7345name = "toml_writer"
7346version = "1.1.3+spec-1.1.0"
7347source = "registry+https://github.com/rust-lang/crates.io-index"
7348checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
7349
7350[[package]]
67847351name = "tonic"
67857352version = "0.14.5"
67867353source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -6946,6 +7513,19 @@
69467513]
69477514
69487515[[package]]
7516name = "tracing-appender"
7517version = "0.2.5"
7518source = "registry+https://github.com/rust-lang/crates.io-index"
7519checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c"
7520dependencies = [
7521 "crossbeam-channel",
7522 "symlink",
7523 "thiserror 2.0.20",
7524 "time 0.3.55",
7525 "tracing-subscriber",
7526]
7527
7528[[package]]
69497529name = "tracing-attributes"
69507530version = "0.1.31"
69517531source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7006,116 +7586,28 @@
70067586 "sharded-slab",
70077587 "smallvec",
70087588 "thread_local",
7589 "time 0.3.55",
70097590 "tracing",
70107591 "tracing-core",
70117592 "tracing-log",
70127593]
70137594
70147595[[package]]
7015name = "traitobject"
7016version = "0.1.1"
7017source = "registry+https://github.com/rust-lang/crates.io-index"
7018checksum = "04a79e25382e2e852e8da874249358d382ebaf259d0d34e75d8db16a7efabbc7"
7019
7020[[package]]
7021name = "tree-sitter"
7022version = "0.25.10"
7596name = "trait-variant"
7597version = "0.1.3"
70237598source = "registry+https://github.com/rust-lang/crates.io-index"
7024checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87"
7599checksum = "b19a4867a870f6edc4c283f2b455804b1879c0baf0e642f26b03ed8ee262d9d3"
70257600dependencies = [
7026 "cc",
7027 "regex",
7028 "regex-syntax",
7029 "serde_json",
7030 "streaming-iterator",
7031 "tree-sitter-language",
7601 "proc-macro2",
7602 "quote",
7603 "syn 2.0.119",
70327604]
70337605
70347606[[package]]
7035name = "tree-sitter-c"
7036version = "0.24.2"
7607name = "traitobject"
7608version = "0.1.1"
70377609source = "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]
7610checksum = "04a79e25382e2e852e8da874249358d382ebaf259d0d34e75d8db16a7efabbc7"
71197611
71207612[[package]]
71217613name = "try-lock"
@@ -7149,12 +7641,24 @@
71497641checksum = "1410f6f91f21d1612654e7cc69193b0334f909dcf2c790c4826254fbb86f8887"
71507642
71517643[[package]]
7644name = "typeid"
7645version = "1.0.3"
7646source = "registry+https://github.com/rust-lang/crates.io-index"
7647checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
7648
7649[[package]]
71527650name = "typenum"
71537651version = "1.20.1"
71547652source = "registry+https://github.com/rust-lang/crates.io-index"
71557653checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
71567654
71577655[[package]]
7656name = "ucd-trie"
7657version = "0.1.7"
7658source = "registry+https://github.com/rust-lang/crates.io-index"
7659checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
7660
7661[[package]]
71587662name = "uncased"
71597663version = "0.9.10"
71607664source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7206,6 +7710,12 @@
72067710checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
72077711
72087712[[package]]
7713name = "unicode-segmentation"
7714version = "1.13.3"
7715source = "registry+https://github.com/rust-lang/crates.io-index"
7716checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
7717
7718[[package]]
72097719name = "unicode-xid"
72107720version = "0.2.6"
72117721source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7273,7 +7783,7 @@
72737783 "async-trait",
72747784 "axum",
72757785 "base64 0.23.1",
7276 "config",
7786 "config 1.0.0",
72777787 "export",
72787788 "futures",
72797789 "i18n",
@@ -7351,6 +7861,7 @@
73517861dependencies = [
73527862 "getrandom 0.4.3",
73537863 "js-sys",
7864 "rand 0.10.2",
73547865 "serde_core",
73557866 "wasm-bindgen",
73567867]
@@ -7579,17 +8090,14 @@
75798090 "auth",
75808091 "axum",
75818092 "chat",
7582 "ci",
7583 "config",
8093 "config 1.0.0",
75848094 "export",
75858095 "git",
7586 "import",
75878096 "issues",
75888097 "jwt",
75898098 "models",
75908099 "pulls",
75918100 "sea-orm",
7592 "search",
75938101 "serde",
75948102 "serde_json",
75958103 "space",
@@ -7601,31 +8109,6 @@
76018109 "utoipa",
76028110 "uuid",
76038111 "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",
76298112]
76308113
76318114[[package]]
@@ -7740,6 +8223,17 @@
77408223checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
77418224
77428225[[package]]
8226name = "windows-registry"
8227version = "0.6.1"
8228source = "registry+https://github.com/rust-lang/crates.io-index"
8229checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
8230dependencies = [
8231 "windows-link",
8232 "windows-result",
8233 "windows-strings",
8234]
8235
8236[[package]]
77438237name = "windows-result"
77448238version = "0.4.1"
77458239source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8026,12 +8520,32 @@
80268520checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6"
80278521
80288522[[package]]
8523name = "yaml-rust2"
8524version = "0.11.1"
8525source = "registry+https://github.com/rust-lang/crates.io-index"
8526checksum = "b36710ce3a279cfce8465dbab826f161675a262950b922cb2c3663852dfe9eb0"
8527dependencies = [
8528 "arraydeque",
8529 "encoding_rs",
8530 "hashlink",
8531]
8532
8533[[package]]
80298534name = "yansi"
80308535version = "1.0.1"
80318536source = "registry+https://github.com/rust-lang/crates.io-index"
80328537checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
80338538
80348539[[package]]
8540name = "yasna"
8541version = "0.5.2"
8542source = "registry+https://github.com/rust-lang/crates.io-index"
8543checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
8544dependencies = [
8545 "time 0.3.55",
8546]
8547
8548[[package]]
80358549name = "yoke"
80368550version = "0.8.3"
80378551source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8187,6 +8701,34 @@
81878701]
81888702
81898703[[package]]
8704name = "zstd"
8705version = "0.13.3"
8706source = "registry+https://github.com/rust-lang/crates.io-index"
8707checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
8708dependencies = [
8709 "zstd-safe",
8710]
8711
8712[[package]]
8713name = "zstd-safe"
8714version = "7.3.0"
8715source = "registry+https://github.com/rust-lang/crates.io-index"
8716checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
8717dependencies = [
8718 "zstd-sys",
8719]
8720
8721[[package]]
8722name = "zstd-sys"
8723version = "2.1.2+zstd.1.5.7"
8724source = "registry+https://github.com/rust-lang/crates.io-index"
8725checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3"
8726dependencies = [
8727 "cc",
8728 "pkg-config",
8729]
8730
8731[[package]]
81908732name = "zune-core"
81918733version = "0.4.12"
81928734source = "registry+https://github.com/rust-lang/crates.io-index"
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,11 +31,10 @@
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" }
37queue = { path = "lib/queue" }
4138web = { path = "web" }
4239
4340
@@ -92,28 +89,15 @@
9289anyhow = "1"
9390tracing-subscriber = { version = "0.3", features = ["env-filter"] }
9491jsonwebtoken = "9"
95hyper = { version = "1", features = ["server", "http1"] }
96hyper-util = { version = "0.1", features = ["tokio"] }
97tokio-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"
10792
108
109serde_yaml_ng = "0.10"
11093regex = "1"
11194url = "2"
11295percent-encoding = "2"
11396encoding_rs = "0.8"
114ipnet = "2"
11597tonic = { version = "0.14", default-features = false, features = ["channel", "codegen", "router", "server"] }
11698tonic-prost = "0.14"
11799prost = "0.14"
118100prost-types = "0.14"
119101tonic-prost-build = "0.14"
102rocketmq-client-rust = "0.9.0"
103rocketmq-common = "0.9.0"
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; 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 /usr/local/bin/
9062
9163USER gitdata
9264
93EXPOSE 3000 9418 22 3001 3002 3003 3005 3006 3007
65EXPOSE 3000
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)
6666up-backend:
6767 @{{ invoke }}"{{ dev }}" up backends
6868
@@ -70,7 +70,7 @@
7070up-frontend:
7171 @{{ invoke }}"{{ dev }}" up frontends
7272
73# 停止全部服务,或指定:just down apihost im
73# 停止全部服务,或指定:just down apihost
7474down *svc='':
7575 @{{ invoke }}"{{ dev }}" down {{ svc }}
7676
@@ -78,7 +78,7 @@
7878restart *svc='':
7979 @{{ invoke }}"{{ dev }}" restart {{ svc }}
8080
81# 最近日志:just logs / just logs apihost / just logs "apihost im"
81# 最近日志:just logs / just logs apihost / just logs "apihost web"
8282logs *svc="apihost":
8383 @{{ invoke }}"{{ dev }}" logs {{ svc }}
8484
@@ -97,9 +97,6 @@
9797# URL 速查
9898urls:
9999 @echo " apihost http://127.0.0.1:4081 (Swagger: /api/docs)"
100 @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"
103100 @echo " web http://127.0.0.1:5173"
104101
105102# 重新生成 openapi.json
Modified openapi.json View openapi.json

Diff too large to display.