Skip to content
Clone

fix: add missing newlines at the end of several files and improve code formatting

ZhenYi committed
parent f0a1552 commit 717a114 Browse files

288 files changed

+13343 −1386

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

File tree 288
Modified admin/src/app/layout.tsx View admin/src/app/layout.tsx
@@ -14,6 +14,7 @@
1414 Users,
1515} from 'lucide-react'
1616import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom'
17import { Cpu } from 'lucide-react'
1718import { useSession } from '@/app/session'
1819import { useTheme } from '@/app/theme'
1920import { Badge, Button, Loading } from '@/shared/ui'
@@ -25,6 +26,7 @@
2526 { to: '/chat', label: '聊天记录', icon: MessagesSquare },
2627 { to: '/storage', label: '存储', icon: HardDrive },
2728 { to: '/queues', label: '队列', icon: Activity },
29 { to: '/runners', label: 'Runner', icon: Cpu },
2830 { to: '/tasks', label: '任务', icon: ListChecks },
2931 { to: '/logs', label: '日志', icon: ScrollText },
3032 { to: '/admins', label: '管理员', icon: ShieldCheck },
@@ -120,4 +122,4 @@
120122 </div>
121123 </div>
122124 )
123}
No newline at end of file
125}
Modified admin/src/app/routes.tsx View admin/src/app/routes.tsx
@@ -10,6 +10,7 @@
1010import { NotFoundPage } from '@/pages/not-found'
1111import { PasswordPage } from '@/pages/password'
1212import { QueuesPage } from '@/pages/queues'
13import { RunnersPage } from '@/pages/runners'
1314import { SpaceDetailPage } from '@/pages/space-detail'
1415import { SpacesPage } from '@/pages/spaces'
1516import { StoragePage } from '@/pages/storage'
@@ -35,6 +36,7 @@
3536 { path: 'chat/messages', element: <ChatMessagesPage /> },
3637 { path: 'storage', element: <StoragePage /> },
3738 { path: 'queues', element: <QueuesPage /> },
39 { path: 'runners', element: <RunnersPage /> },
3840 { path: 'tasks', element: <TasksPage /> },
3941 { path: 'logs', element: <LogsPage /> },
4042 { path: 'admins', element: <AdminsPage /> },
@@ -43,4 +45,4 @@
4345 ],
4446 },
4547 { path: '*', element: <Navigate to="/" replace /> },
46])
No newline at end of file
48])
Added admin/src/pages/runners.tsx View admin/src/pages/runners.tsx
@@ -0,0 +1,270 @@
1import { useCallback, useEffect, useState } from 'react'
2import { adminRunners } from '@/shared/api'
3import type { CreatedRunnerToken, RunnerSummary, RunnerTokenSummary } from '@/shared/api'
4import { ApiError } from '@/shared/api'
5import { formatDateTime } from '@/shared/format'
6import {
7 Badge,
8 Button,
9 Card,
10 ConfirmDialog,
11 DataTable,
12 ErrorBanner,
13 Loading,
14 Modal,
15 useToast,
16 type Column,
17} from '@/shared/ui'
18
19const runnerColumns: Column<RunnerSummary>[] = [
20 { key: 'name', title: '名称', render: (row) => row.name },
21 { key: 'labels', title: '标签', render: (row) => row.labels.join(', ') },
22 {
23 key: 'capacity',
24 title: '并发',
25 render: (row) => `${row.active_jobs}/${row.capacity}`,
26 },
27 {
28 key: 'state',
29 title: '状态',
30 render: (row) => (
31 <Badge tone={row.state === 'active' ? 'success' : 'warning'}>
32 {row.state === 'active' ? '启用' : '停用'}
33 </Badge>
34 ),
35 },
36 {
37 key: 'last_seen',
38 title: '最近心跳',
39 render: (row) => (row.last_seen_at ? formatDateTime(row.last_seen_at) : '—'),
40 },
41]
42
43const tokenColumns: Column<RunnerTokenSummary>[] = [
44 { key: 'name', title: '名称', render: (row) => row.name },
45 { key: 'scope', title: '作用域', render: (row) => row.space_id ?? '全局' },
46 {
47 key: 'expires',
48 title: '过期',
49 render: (row) => (row.expires_at ? formatDateTime(row.expires_at) : '永不过期'),
50 },
51 {
52 key: 'state',
53 title: '状态',
54 render: (row) =>
55 row.revoked_at ? '已吊销' : row.used_at ? '已使用' : '可用',
56 },
57 { key: 'created', title: '创建时间', render: (row) => formatDateTime(row.created_at) },
58]
59
60export function RunnersPage() {
61 const toast = useToast()
62 const [runners, setRunners] = useState<RunnerSummary[] | null>(null)
63 const [tokens, setTokens] = useState<RunnerTokenSummary[] | null>(null)
64 const [error, setError] = useState<string | null>(null)
65 const [deleteTarget, setDeleteTarget] = useState<RunnerSummary | null>(null)
66 const [tokenOpen, setTokenOpen] = useState(false)
67 const [tokenName, setTokenName] = useState('')
68 const [tokenDays, setTokenDays] = useState('30')
69 const [created, setCreated] = useState<CreatedRunnerToken | null>(null)
70
71 const load = useCallback(async () => {
72 setError(null)
73 try {
74 const [page, tokenList] = await Promise.all([
75 adminRunners.list({ limit: 100, offset: 0 }),
76 adminRunners.tokens(),
77 ])
78 setRunners(page.items)
79 setTokens(tokenList)
80 } catch (err) {
81 setError(err instanceof ApiError ? err.message : 'runner 列表加载失败')
82 }
83 }, [])
84
85 useEffect(() => {
86 void load()
87 const timer = window.setInterval(() => {
88 void load()
89 }, 15000)
90 return () => window.clearInterval(timer)
91 }, [load])
92
93 const toggle = async (runner: RunnerSummary) => {
94 try {
95 await adminRunners.update(runner.id, { enabled: runner.state !== 'active' })
96 toast(runner.state === 'active' ? 'runner 已停用' : 'runner 已启用')
97 void load()
98 } catch (err) {
99 toast(err instanceof ApiError ? err.message : '操作失败', 'danger')
100 }
101 }
102
103 const remove = async () => {
104 if (!deleteTarget) return
105 try {
106 await adminRunners.remove(deleteTarget.id)
107 toast('runner 已删除')
108 setDeleteTarget(null)
109 void load()
110 } catch (err) {
111 toast(err instanceof ApiError ? err.message : '删除失败', 'danger')
112 }
113 }
114
115 const createToken = async () => {
116 try {
117 const result = await adminRunners.createToken({
118 name: tokenName.trim(),
119 expires_in_days: Number.parseInt(tokenDays, 10) || undefined,
120 })
121 setCreated(result)
122 setTokenName('')
123 setTokenOpen(false)
124 void load()
125 } catch (err) {
126 toast(err instanceof ApiError ? err.message : '令牌创建失败', 'danger')
127 }
128 }
129
130 const revoke = async (token: RunnerTokenSummary) => {
131 try {
132 await adminRunners.revokeToken(token.id)
133 toast('令牌已吊销')
134 void load()
135 } catch (err) {
136 toast(err instanceof ApiError ? err.message : '吊销失败', 'danger')
137 }
138 }
139
140 return (
141 <div className="space-y-5">
142 <div className="flex items-center justify-between gap-3">
143 <h1 className="text-xl font-semibold">Runner</h1>
144 <div className="flex gap-2">
145 <Button variant="secondary" onClick={() => void load()}>
146 刷新
147 </Button>
148 <Button onClick={() => setTokenOpen(true)}>创建注册令牌</Button>
149 </div>
150 </div>
151
152 <ErrorBanner message={error} />
153
154 {created ? (
155 <Card title="注册令牌(只显示一次)">
156 <code className="block break-all rounded-md bg-slate-100 px-3 py-2 text-xs dark:bg-slate-800">
157 {created.plaintext}
158 </code>
159 <p className="mt-2 text-xs text-slate-500">
160 在目标机器上设置 <code>APP_RUNNER__REGISTRATION_TOKEN</code> 后启动 runner。
161 </p>
162 <div className="mt-3">
163 <Button variant="secondary" onClick={() => setCreated(null)}>
164 我已保存
165 </Button>
166 </div>
167 </Card>
168 ) : null}
169
170 <Card title="已注册 runner">
171 {runners === null ? (
172 <Loading />
173 ) : (
174 <DataTable
175 columns={[
176 ...runnerColumns,
177 {
178 key: 'actions',
179 title: '操作',
180 render: (row) => (
181 <div className="flex gap-2">
182 <Button variant="secondary" onClick={() => void toggle(row)}>
183 {row.state === 'active' ? '停用' : '启用'}
184 </Button>
185 <Button variant="danger" onClick={() => setDeleteTarget(row)}>
186 删除
187 </Button>
188 </div>
189 ),
190 },
191 ]}
192 rows={runners}
193 rowKey={(row) => row.id}
194 empty="还没有 runner 注册"
195 />
196 )}
197 </Card>
198
199 <Card title="注册令牌">
200 {tokens === null ? (
201 <Loading />
202 ) : (
203 <DataTable
204 columns={[
205 ...tokenColumns,
206 {
207 key: 'actions',
208 title: '操作',
209 render: (row) =>
210 row.revoked_at ? (
211 '—'
212 ) : (
213 <Button variant="danger" onClick={() => void revoke(row)}>
214 吊销
215 </Button>
216 ),
217 },
218 ]}
219 rows={tokens}
220 rowKey={(row) => row.id}
221 empty="没有注册令牌"
222 />
223 )}
224 </Card>
225
226 <Modal
227 open={tokenOpen}
228 title="创建注册令牌"
229 onClose={() => setTokenOpen(false)}
230 footer={
231 <>
232 <Button variant="secondary" onClick={() => setTokenOpen(false)}>
233 取消
234 </Button>
235 <Button disabled={!tokenName.trim()} onClick={() => void createToken()}>
236 创建
237 </Button>
238 </>
239 }
240 >
241 <label className="block">
242 <span className="mb-1.5 block text-xs font-medium">名称</span>
243 <input
244 className="h-9 w-full rounded-md border border-slate-300 bg-white px-2 text-sm dark:border-slate-600 dark:bg-slate-800"
245 value={tokenName}
246 onChange={(event) => setTokenName(event.target.value)}
247 />
248 </label>
249 <label className="block">
250 <span className="mb-1.5 block text-xs font-medium">有效天数(留空为永久)</span>
251 <input
252 className="h-9 w-full rounded-md border border-slate-300 bg-white px-2 text-sm dark:border-slate-600 dark:bg-slate-800"
253 value={tokenDays}
254 onChange={(event) => setTokenDays(event.target.value)}
255 />
256 </label>
257 </Modal>
258
259 <ConfirmDialog
260 open={deleteTarget !== null}
261 title="删除 runner"
262 description={`确定删除 runner「${deleteTarget?.name ?? ''}」吗?其凭据会立即失效。`}
263 confirmLabel="删除"
264 onClose={() => setDeleteTarget(null)}
265 onConfirm={() => void remove()}
266 />
267
268 </div>
269 )
270}
Modified admin/src/shared/api-types.ts View admin/src/shared/api-types.ts
@@ -598,3 +598,50 @@
598598 user_id: string
599599 removed: boolean
600600}
601
602export interface RunnerSummary {
603 id: string
604 name: string
605 labels: string[]
606 capacity: number
607 version: string | null
608 state: string
609 space_id: string | null
610 active_jobs: number
611 last_seen_at: string | null
612 created_at: string
613}
614
615export interface RunnerPage {
616 items: RunnerSummary[]
617 total: number
618 limit: number
619 offset: number
620}
621
622export interface RunnerTokenSummary {
623 id: string
624 name: string
625 space_id: string | null
626 single_use: boolean
627 used_at: string | null
628 revoked_at: string | null
629 expires_at: string | null
630 created_at: string
631}
632
633export interface CreatedRunnerToken {
634 token: RunnerTokenSummary
635 plaintext: string
636}
637
638export interface EnvironmentAdminSummary {
639 id: string
640 space_id: string
641 name: string
642 description: string | null
643 required_labels: string[]
644 secrets: string[]
645 created_at: string
646 updated_at: string
647}
Modified admin/src/shared/api.ts View admin/src/shared/api.ts
@@ -29,6 +29,11 @@
2929 QueueActionResp,
3030 PurgeChatMessagesResp,
3131 QueueSnapshot,
32 RunnerPage,
33 RunnerSummary,
34 RunnerTokenSummary,
35 CreatedRunnerToken,
36 EnvironmentAdminSummary,
3237 RemoveChatMemberResp,
3338 StorageObjectPage,
3439 StorageOverview,
@@ -372,4 +377,32 @@
372377 status: () => request<SystemStatus>('GET', '/admin/system/status'),
373378 dashboard: () => request<Dashboard>('GET', '/admin/dashboard'),
374379 config: () => request<SystemConfig>('GET', '/admin/system/config'),
375}
No newline at end of file
380}
381export const adminRunners = {
382 list: (params: { limit?: number; offset?: number }) =>
383 request<RunnerPage>('GET', '/admin/runners', { params }),
384 update: (id: string, body: { labels?: string[]; capacity?: number; enabled?: boolean }) =>
385 request<RunnerSummary>('PATCH', `/admin/runners/${id}`, { body }),
386 remove: (id: string) => request<RunnerSummary[]>('DELETE', `/admin/runners/${id}`),
387 tokens: () => request<RunnerTokenSummary[]>('GET', '/admin/runner-tokens'),
388 createToken: (body: { name: string; space_id?: string | null; expires_in_days?: number }) =>
389 request<CreatedRunnerToken>('POST', '/admin/runner-tokens', { body }),
390 revokeToken: (id: string) =>
391 request<RunnerTokenSummary[]>('POST', `/admin/runner-tokens/${id}/revoke`),
392 environments: (spaceId: string) =>
393 request<EnvironmentAdminSummary[]>('GET', `/admin/spaces/${spaceId}/environments`),
394 upsertEnvironment: (
395 spaceId: string,
396 body: { name: string; description?: string | null; required_labels?: string[] },
397 ) => request<EnvironmentAdminSummary>('PUT', `/admin/spaces/${spaceId}/environments`, { body }),
398 removeEnvironment: (id: string) =>
399 request<EnvironmentAdminSummary[]>('DELETE', `/admin/environments/${id}`),
400 upsertEnvironmentSecret: (id: string, body: { name: string; value: string }) =>
401 request<EnvironmentAdminSummary[]>('PUT', `/admin/environments/${id}/secrets`, { body }),
402 removeEnvironmentSecret: (id: string, name: string) =>
403 request<EnvironmentAdminSummary[]>(
404 'DELETE',
405 `/admin/environments/${id}/secrets/${encodeURIComponent(name)}`,
406 ),
407}
408
Modified admin/build.rs View admin/build.rs
@@ -42,7 +42,10 @@
4242 collect(&dist, &dist, &mut files);
4343
4444 let mut src = String::from("// @generated by build.rs — embedded admin frontend (dist/)\n\n");
45 src.push_str(&format!("pub const INDEX: &[u8] = include_bytes!({:?});\n\n", index));
45 src.push_str(&format!(
46 "pub const INDEX: &[u8] = include_bytes!({:?});\n\n",
47 index
48 ));
4649 src.push_str("pub const FILES: &[(&str, &[u8])] = &[\n");
4750 for (rel, abs) in &files {
4851 if rel == "index.html" {
@@ -78,7 +81,11 @@
7881 steps.push((tool, &["run", "build"]));
7982
8083 for (t, args) in steps {
81 eprintln!("cargo:warning=running `{t} {}` in {}", args.join(" "), root.display());
84 eprintln!(
85 "cargo:warning=running `{t} {}` in {}",
86 args.join(" "),
87 root.display()
88 );
8289 let ok = Command::new(t)
8390 .args(args)
8491 .current_dir(root)
@@ -125,4 +132,4 @@
125132 out.insert(rel, path);
126133 }
127134 }
128}
No newline at end of file
135}
Modified admin/lib.rs View admin/lib.rs
@@ -89,4 +89,4 @@
8989 "pdf" => "application/pdf",
9090 _ => "application/octet-stream",
9191 }
92}
No newline at end of file
92}
Modified admin/openapi.json View admin/openapi.json
@@ -1447,6 +1447,120 @@
14471447 }
14481448 }
14491449 },
1450 "/admin/environments/{id}": {
1451 "delete": {
1452 "tags": [
1453 "admin-runners"
1454 ],
1455 "description": "删除部署环境及其 secrets 与部署历史。",
1456 "operationId": "delete_environment",
1457 "parameters": [
1458 {
1459 "name": "id",
1460 "in": "path",
1461 "required": true,
1462 "schema": {
1463 "type": "string",
1464 "format": "uuid"
1465 }
1466 }
1467 ],
1468 "responses": {
1469 "200": {
1470 "description": "environment deleted",
1471 "content": {
1472 "application/json": {
1473 "schema": {
1474 "$ref": "#/components/schemas/AppResponseData_Vec_EnvironmentAdminDto"
1475 }
1476 }
1477 }
1478 }
1479 }
1480 }
1481 },
1482 "/admin/environments/{id}/secrets": {
1483 "put": {
1484 "tags": [
1485 "admin-runners"
1486 ],
1487 "description": "创建或更新环境级 secret。",
1488 "operationId": "upsert_environment_secret",
1489 "parameters": [
1490 {
1491 "name": "id",
1492 "in": "path",
1493 "required": true,
1494 "schema": {
1495 "type": "string",
1496 "format": "uuid"
1497 }
1498 }
1499 ],
1500 "requestBody": {
1501 "content": {
1502 "application/json": {
1503 "schema": {
1504 "$ref": "#/components/schemas/UpsertEnvironmentSecretCmd"
1505 }
1506 }
1507 },
1508 "required": true
1509 },
1510 "responses": {
1511 "200": {
1512 "description": "secret upserted",
1513 "content": {
1514 "application/json": {
1515 "schema": {
1516 "$ref": "#/components/schemas/AppResponseData_Vec_EnvironmentAdminDto"
1517 }
1518 }
1519 }
1520 }
1521 }
1522 }
1523 },
1524 "/admin/environments/{id}/secrets/{name}": {
1525 "delete": {
1526 "tags": [
1527 "admin-runners"
1528 ],
1529 "description": "删除环境级 secret。",
1530 "operationId": "delete_environment_secret",
1531 "parameters": [
1532 {
1533 "name": "id",
1534 "in": "path",
1535 "required": true,
1536 "schema": {
1537 "type": "string",
1538 "format": "uuid"
1539 }
1540 },
1541 {
1542 "name": "name",
1543 "in": "path",
1544 "required": true,
1545 "schema": {
1546 "type": "string"
1547 }
1548 }
1549 ],
1550 "responses": {
1551 "200": {
1552 "description": "secret deleted",
1553 "content": {
1554 "application/json": {
1555 "schema": {
1556 "$ref": "#/components/schemas/AppResponseData_Vec_EnvironmentAdminDto"
1557 }
1558 }
1559 }
1560 }
1561 }
1562 }
1563 },
14501564 "/admin/logs/audit": {
14511565 "get": {
14521566 "tags": [
@@ -2089,6 +2203,209 @@
20892203 }
20902204 }
20912205 },
2206 "/admin/runner-tokens": {
2207 "get": {
2208 "tags": [
2209 "admin-runners"
2210 ],
2211 "description": "列出注册令牌(不含明文)。",
2212 "operationId": "list_runner_tokens",
2213 "responses": {
2214 "200": {
2215 "description": "token list",
2216 "content": {
2217 "application/json": {
2218 "schema": {
2219 "$ref": "#/components/schemas/AppResponseData_Vec_RunnerTokenDto"
2220 }
2221 }
2222 }
2223 }
2224 }
2225 },
2226 "post": {
2227 "tags": [
2228 "admin-runners"
2229 ],
2230 "description": "创建一次性 runner 注册令牌;明文只在本次响应中返回。",
2231 "operationId": "create_runner_token",
2232 "requestBody": {
2233 "content": {
2234 "application/json": {
2235 "schema": {
2236 "$ref": "#/components/schemas/CreateRunnerTokenCmd"
2237 }
2238 }
2239 },
2240 "required": true
2241 },
2242 "responses": {
2243 "200": {
2244 "description": "token created",
2245 "content": {
2246 "application/json": {
2247 "schema": {
2248 "$ref": "#/components/schemas/AppResponseData_CreatedRunnerTokenDto"
2249 }
2250 }
2251 }
2252 }
2253 }
2254 }
2255 },
2256 "/admin/runner-tokens/{id}/revoke": {
2257 "post": {
2258 "tags": [
2259 "admin-runners"
2260 ],
2261 "description": "吊销注册令牌或 runner 凭据。",
2262 "operationId": "revoke_runner_token",
2263 "parameters": [
2264 {
2265 "name": "id",
2266 "in": "path",
2267 "required": true,
2268 "schema": {
2269 "type": "string",
2270 "format": "uuid"
2271 }
2272 }
2273 ],
2274 "responses": {
2275 "200": {
2276 "description": "token revoked",
2277 "content": {
2278 "application/json": {
2279 "schema": {
2280 "$ref": "#/components/schemas/AppResponseData_Vec_RunnerTokenDto"
2281 }
2282 }
2283 }
2284 }
2285 }
2286 }
2287 },
2288 "/admin/runners": {
2289 "get": {
2290 "tags": [
2291 "admin-runners"
2292 ],
2293 "description": "列出已注册的 runner 及其活跃作业数。",
2294 "operationId": "list_runners",
2295 "parameters": [
2296 {
2297 "name": "limit",
2298 "in": "query",
2299 "required": false,
2300 "schema": {
2301 "type": [
2302 "integer",
2303 "null"
2304 ],
2305 "format": "int64",
2306 "minimum": 0
2307 }
2308 },
2309 {
2310 "name": "offset",
2311 "in": "query",
2312 "required": false,
2313 "schema": {
2314 "type": [
2315 "integer",
2316 "null"
2317 ],
2318 "format": "int64",
2319 "minimum": 0
2320 }
2321 }
2322 ],
2323 "responses": {
2324 "200": {
2325 "description": "runner list",
2326 "content": {
2327 "application/json": {
2328 "schema": {
2329 "$ref": "#/components/schemas/AppResponseData_RunnerPageDto"
2330 }
2331 }
2332 }
2333 }
2334 }
2335 }
2336 },
2337 "/admin/runners/{id}": {
2338 "delete": {
2339 "tags": [
2340 "admin-runners"
2341 ],
2342 "description": "删除 runner 及其凭据。",
2343 "operationId": "delete_runner",
2344 "parameters": [
2345 {
2346 "name": "id",
2347 "in": "path",
2348 "required": true,
2349 "schema": {
2350 "type": "string",
2351 "format": "uuid"
2352 }
2353 }
2354 ],
2355 "responses": {
2356 "200": {
2357 "description": "runner deleted",
2358 "content": {
2359 "application/json": {
2360 "schema": {
2361 "$ref": "#/components/schemas/AppResponseData_Vec_RunnerDto"
2362 }
2363 }
2364 }
2365 }
2366 }
2367 },
2368 "patch": {
2369 "tags": [
2370 "admin-runners"
2371 ],
2372 "description": "更新 runner 的标签、容量或启用状态。",
2373 "operationId": "update_runner",
2374 "parameters": [
2375 {
2376 "name": "id",
2377 "in": "path",
2378 "required": true,
2379 "schema": {
2380 "type": "string",
2381 "format": "uuid"
2382 }
2383 }
2384 ],
2385 "requestBody": {
2386 "content": {
2387 "application/json": {
2388 "schema": {
2389 "$ref": "#/components/schemas/UpdateRunnerCmd"
2390 }
2391 }
2392 },
2393 "required": true
2394 },
2395 "responses": {
2396 "200": {
2397 "description": "runner updated",
2398 "content": {
2399 "application/json": {
2400 "schema": {
2401 "$ref": "#/components/schemas/AppResponseData_RunnerDto"
2402 }
2403 }
2404 }
2405 }
2406 }
2407 }
2408 },
20922409 "/admin/spaces": {
20932410 "get": {
20942411 "tags": [
@@ -2317,6 +2634,78 @@
23172634 }
23182635 }
23192636 },
2637 "/admin/spaces/{id}/environments": {
2638 "get": {
2639 "tags": [
2640 "admin-runners"
2641 ],
2642 "description": "列出某个空间的部署环境与已配置的 secret 名称。",
2643 "operationId": "list_environments",
2644 "parameters": [
2645 {
2646 "name": "id",
2647 "in": "path",
2648 "required": true,
2649 "schema": {
2650 "type": "string",
2651 "format": "uuid"
2652 }
2653 }
2654 ],
2655 "responses": {
2656 "200": {
2657 "description": "environment list",
2658 "content": {
2659 "application/json": {
2660 "schema": {
2661 "$ref": "#/components/schemas/AppResponseData_Vec_EnvironmentAdminDto"
2662 }
2663 }
2664 }
2665 }
2666 }
2667 },
2668 "put": {
2669 "tags": [
2670 "admin-runners"
2671 ],
2672 "description": "创建或更新空间下的部署环境。",
2673 "operationId": "upsert_environment",
2674 "parameters": [
2675 {
2676 "name": "id",
2677 "in": "path",
2678 "required": true,
2679 "schema": {
2680 "type": "string",
2681 "format": "uuid"
2682 }
2683 }
2684 ],
2685 "requestBody": {
2686 "content": {
2687 "application/json": {
2688 "schema": {
2689 "$ref": "#/components/schemas/UpsertEnvironmentAdminCmd"
2690 }
2691 }
2692 },
2693 "required": true
2694 },
2695 "responses": {
2696 "200": {
2697 "description": "environment upserted",
2698 "content": {
2699 "application/json": {
2700 "schema": {
2701 "$ref": "#/components/schemas/AppResponseData_EnvironmentAdminDto"
2702 }
2703 }
2704 }
2705 }
2706 }
2707 }
2708 },
23202709 "/admin/spaces/{id}/members": {
23212710 "get": {
23222711 "tags": [
@@ -6546,17 +6935,73 @@
65466935 "type": "string"
65476936 },
65486937 "failed": {
6549 "type": "array",
6550 "items": {
6551 "$ref": "#/components/schemas/BulkUserFailure"
6552 }
6938 "type": "array",
6939 "items": {
6940 "$ref": "#/components/schemas/BulkUserFailure"
6941 }
6942 },
6943 "succeeded": {
6944 "type": "array",
6945 "items": {
6946 "type": "string",
6947 "format": "uuid"
6948 }
6949 }
6950 }
6951 },
6952 "error": {
6953 "type": [
6954 "string",
6955 "null"
6956 ]
6957 },
6958 "error_code": {
6959 "type": [
6960 "string",
6961 "null"
6962 ]
6963 },
6964 "log_id": {
6965 "type": "string"
6966 },
6967 "message": {
6968 "type": "string"
6969 },
6970 "request_id": {
6971 "type": "string"
6972 }
6973 }
6974 },
6975 "AppResponseData_CreateUserResp": {
6976 "type": "object",
6977 "required": [
6978 "code",
6979 "message",
6980 "request_id",
6981 "log_id"
6982 ],
6983 "properties": {
6984 "code": {
6985 "type": "integer",
6986 "format": "int32"
6987 },
6988 "data": {
6989 "type": "object",
6990 "required": [
6991 "uid",
6992 "username",
6993 "email"
6994 ],
6995 "properties": {
6996 "email": {
6997 "type": "string"
6998 },
6999 "uid": {
7000 "type": "string",
7001 "format": "uuid"
65537002 },
6554 "succeeded": {
6555 "type": "array",
6556 "items": {
6557 "type": "string",
6558 "format": "uuid"
6559 }
7003 "username": {
7004 "type": "string"
65607005 }
65617006 }
65627007 },
@@ -6583,7 +7028,7 @@
65837028 }
65847029 }
65857030 },
6586 "AppResponseData_CreateUserResp": {
7031 "AppResponseData_CreatedRunnerTokenDto": {
65877032 "type": "object",
65887033 "required": [
65897034 "code",
@@ -6599,20 +7044,15 @@
65997044 "data": {
66007045 "type": "object",
66017046 "required": [
6602 "uid",
6603 "username",
6604 "email"
7047 "token",
7048 "plaintext"
66057049 ],
66067050 "properties": {
6607 "email": {
7051 "plaintext": {
66087052 "type": "string"
66097053 },
6610 "uid": {
6611 "type": "string",
6612 "format": "uuid"
6613 },
6614 "username": {
6615 "type": "string"
7054 "token": {
7055 "$ref": "#/components/schemas/RunnerTokenDto"
66167056 }
66177057 }
66187058 },
@@ -7021,6 +7461,93 @@
70217461 }
70227462 }
70237463 },
7464 "AppResponseData_EnvironmentAdminDto": {
7465 "type": "object",
7466 "required": [
7467 "code",
7468 "message",
7469 "request_id",
7470 "log_id"
7471 ],
7472 "properties": {
7473 "code": {
7474 "type": "integer",
7475 "format": "int32"
7476 },
7477 "data": {
7478 "type": "object",
7479 "required": [
7480 "id",
7481 "space_id",
7482 "name",
7483 "required_labels",
7484 "secrets",
7485 "created_at",
7486 "updated_at"
7487 ],
7488 "properties": {
7489 "created_at": {
7490 "type": "string",
7491 "format": "date-time"
7492 },
7493 "description": {
7494 "type": [
7495 "string",
7496 "null"
7497 ]
7498 },
7499 "id": {
7500 "type": "string",
7501 "format": "uuid"
7502 },
7503 "name": {
7504 "type": "string"
7505 },
7506 "required_labels": {
7507 "type": "array",
7508 "items": {
7509 "type": "string"
7510 }
7511 },
7512 "secrets": {
7513 "type": "array",
7514 "items": {
7515 "type": "string"
7516 }
7517 },
7518 "space_id": {
7519 "type": "string",
7520 "format": "uuid"
7521 },
7522 "updated_at": {
7523 "type": "string",
7524 "format": "date-time"
7525 }
7526 }
7527 },
7528 "error": {
7529 "type": [
7530 "string",
7531 "null"
7532 ]
7533 },
7534 "error_code": {
7535 "type": [
7536 "string",
7537 "null"
7538 ]
7539 },
7540 "log_id": {
7541 "type": "string"
7542 },
7543 "message": {
7544 "type": "string"
7545 },
7546 "request_id": {
7547 "type": "string"
7548 }
7549 }
7550 },
70247551 "AppResponseData_JobLogPageDto": {
70257552 "type": "object",
70267553 "required": [
@@ -8450,8 +8977,174 @@
84508977 "required": [
84518978 "revoked"
84528979 ],
8453 "properties": {
8454 "revoked": {
8980 "properties": {
8981 "revoked": {
8982 "type": "integer",
8983 "format": "int64",
8984 "minimum": 0
8985 }
8986 }
8987 },
8988 "error": {
8989 "type": [
8990 "string",
8991 "null"
8992 ]
8993 },
8994 "error_code": {
8995 "type": [
8996 "string",
8997 "null"
8998 ]
8999 },
9000 "log_id": {
9001 "type": "string"
9002 },
9003 "message": {
9004 "type": "string"
9005 },
9006 "request_id": {
9007 "type": "string"
9008 }
9009 }
9010 },
9011 "AppResponseData_RunnerDto": {
9012 "type": "object",
9013 "required": [
9014 "code",
9015 "message",
9016 "request_id",
9017 "log_id"
9018 ],
9019 "properties": {
9020 "code": {
9021 "type": "integer",
9022 "format": "int32"
9023 },
9024 "data": {
9025 "type": "object",
9026 "required": [
9027 "id",
9028 "name",
9029 "labels",
9030 "capacity",
9031 "state",
9032 "active_jobs",
9033 "created_at"
9034 ],
9035 "properties": {
9036 "active_jobs": {
9037 "type": "integer",
9038 "format": "int64"
9039 },
9040 "capacity": {
9041 "type": "integer",
9042 "format": "int32"
9043 },
9044 "created_at": {
9045 "type": "string",
9046 "format": "date-time"
9047 },
9048 "id": {
9049 "type": "string",
9050 "format": "uuid"
9051 },
9052 "labels": {
9053 "type": "array",
9054 "items": {
9055 "type": "string"
9056 }
9057 },
9058 "last_seen_at": {
9059 "type": [
9060 "string",
9061 "null"
9062 ],
9063 "format": "date-time"
9064 },
9065 "name": {
9066 "type": "string"
9067 },
9068 "space_id": {
9069 "type": [
9070 "string",
9071 "null"
9072 ],
9073 "format": "uuid"
9074 },
9075 "state": {
9076 "type": "string"
9077 },
9078 "version": {
9079 "type": [
9080 "string",
9081 "null"
9082 ]
9083 }
9084 }
9085 },
9086 "error": {
9087 "type": [
9088 "string",
9089 "null"
9090 ]
9091 },
9092 "error_code": {
9093 "type": [
9094 "string",
9095 "null"
9096 ]
9097 },
9098 "log_id": {
9099 "type": "string"
9100 },
9101 "message": {
9102 "type": "string"
9103 },
9104 "request_id": {
9105 "type": "string"
9106 }
9107 }
9108 },
9109 "AppResponseData_RunnerPageDto": {
9110 "type": "object",
9111 "required": [
9112 "code",
9113 "message",
9114 "request_id",
9115 "log_id"
9116 ],
9117 "properties": {
9118 "code": {
9119 "type": "integer",
9120 "format": "int32"
9121 },
9122 "data": {
9123 "type": "object",
9124 "required": [
9125 "items",
9126 "total",
9127 "limit",
9128 "offset"
9129 ],
9130 "properties": {
9131 "items": {
9132 "type": "array",
9133 "items": {
9134 "$ref": "#/components/schemas/RunnerDto"
9135 }
9136 },
9137 "limit": {
9138 "type": "integer",
9139 "format": "int64",
9140 "minimum": 0
9141 },
9142 "offset": {
9143 "type": "integer",
9144 "format": "int64",
9145 "minimum": 0
9146 },
9147 "total": {
84559148 "type": "integer",
84569149 "format": "int64",
84579150 "minimum": 0
@@ -9025,6 +9718,96 @@
90259718 }
90269719 }
90279720 },
9721 "AppResponseData_Vec_EnvironmentAdminDto": {
9722 "type": "object",
9723 "required": [
9724 "code",
9725 "message",
9726 "request_id",
9727 "log_id"
9728 ],
9729 "properties": {
9730 "code": {
9731 "type": "integer",
9732 "format": "int32"
9733 },
9734 "data": {
9735 "type": "array",
9736 "items": {
9737 "type": "object",
9738 "required": [
9739 "id",
9740 "space_id",
9741 "name",
9742 "required_labels",
9743 "secrets",
9744 "created_at",
9745 "updated_at"
9746 ],
9747 "properties": {
9748 "created_at": {
9749 "type": "string",
9750 "format": "date-time"
9751 },
9752 "description": {
9753 "type": [
9754 "string",
9755 "null"
9756 ]
9757 },
9758 "id": {
9759 "type": "string",
9760 "format": "uuid"
9761 },
9762 "name": {
9763 "type": "string"
9764 },
9765 "required_labels": {
9766 "type": "array",
9767 "items": {
9768 "type": "string"
9769 }
9770 },
9771 "secrets": {
9772 "type": "array",
9773 "items": {
9774 "type": "string"
9775 }
9776 },
9777 "space_id": {
9778 "type": "string",
9779 "format": "uuid"
9780 },
9781 "updated_at": {
9782 "type": "string",
9783 "format": "date-time"
9784 }
9785 }
9786 }
9787 },
9788 "error": {
9789 "type": [
9790 "string",
9791 "null"
9792 ]
9793 },
9794 "error_code": {
9795 "type": [
9796 "string",
9797 "null"
9798 ]
9799 },
9800 "log_id": {
9801 "type": "string"
9802 },
9803 "message": {
9804 "type": "string"
9805 },
9806 "request_id": {
9807 "type": "string"
9808 }
9809 }
9810 },
90289811 "AppResponseData_Vec_QueueSnapshotDto": {
90299812 "type": "object",
90309813 "required": [
@@ -9116,6 +9899,199 @@
91169899 }
91179900 }
91189901 },
9902 "AppResponseData_Vec_RunnerDto": {
9903 "type": "object",
9904 "required": [
9905 "code",
9906 "message",
9907 "request_id",
9908 "log_id"
9909 ],
9910 "properties": {
9911 "code": {
9912 "type": "integer",
9913 "format": "int32"
9914 },
9915 "data": {
9916 "type": "array",
9917 "items": {
9918 "type": "object",
9919 "required": [
9920 "id",
9921 "name",
9922 "labels",
9923 "capacity",
9924 "state",
9925 "active_jobs",
9926 "created_at"
9927 ],
9928 "properties": {
9929 "active_jobs": {
9930 "type": "integer",
9931 "format": "int64"
9932 },
9933 "capacity": {
9934 "type": "integer",
9935 "format": "int32"
9936 },
9937 "created_at": {
9938 "type": "string",
9939 "format": "date-time"
9940 },
9941 "id": {
9942 "type": "string",
9943 "format": "uuid"
9944 },
9945 "labels": {
9946 "type": "array",
9947 "items": {
9948 "type": "string"
9949 }
9950 },
9951 "last_seen_at": {
9952 "type": [
9953 "string",
9954 "null"
9955 ],
9956 "format": "date-time"
9957 },
9958 "name": {
9959 "type": "string"
9960 },
9961 "space_id": {
9962 "type": [
9963 "string",
9964 "null"
9965 ],
9966 "format": "uuid"
9967 },
9968 "state": {
9969 "type": "string"
9970 },
9971 "version": {
9972 "type": [
9973 "string",
9974 "null"
9975 ]
9976 }
9977 }
9978 }
9979 },
9980 "error": {
9981 "type": [
9982 "string",
9983 "null"
9984 ]
9985 },
9986 "error_code": {
9987 "type": [
9988 "string",
9989 "null"
9990 ]
9991 },
9992 "log_id": {
9993 "type": "string"
9994 },
9995 "message": {
9996 "type": "string"
9997 },
9998 "request_id": {
9999 "type": "string"
10000 }
10001 }
10002 },
10003 "AppResponseData_Vec_RunnerTokenDto": {
10004 "type": "object",
10005 "required": [
10006 "code",
10007 "message",
10008 "request_id",
10009 "log_id"
10010 ],
10011 "properties": {
10012 "code": {
10013 "type": "integer",
10014 "format": "int32"
10015 },
10016 "data": {
10017 "type": "array",
10018 "items": {
10019 "type": "object",
10020 "required": [
10021 "id",
10022 "name",
10023 "single_use",
10024 "created_at"
10025 ],
10026 "properties": {
10027 "created_at": {
10028 "type": "string",
10029 "format": "date-time"
10030 },
10031 "expires_at": {
10032 "type": [
10033 "string",
10034 "null"
10035 ],
10036 "format": "date-time"
10037 },
10038 "id": {
10039 "type": "string",
10040 "format": "uuid"
10041 },
10042 "name": {
10043 "type": "string"
10044 },
10045 "revoked_at": {
10046 "type": [
10047 "string",
10048 "null"
10049 ],
10050 "format": "date-time"
10051 },
10052 "single_use": {
10053 "type": "boolean"
10054 },
10055 "space_id": {
10056 "type": [
10057 "string",
10058 "null"
10059 ],
10060 "format": "uuid"
10061 },
10062 "used_at": {
10063 "type": [
10064 "string",
10065 "null"
10066 ],
10067 "format": "date-time"
10068 }
10069 }
10070 }
10071 },
10072 "error": {
10073 "type": [
10074 "string",
10075 "null"
10076 ]
10077 },
10078 "error_code": {
10079 "type": [
10080 "string",
10081 "null"
10082 ]
10083 },
10084 "log_id": {
10085 "type": "string"
10086 },
10087 "message": {
10088 "type": "string"
10089 },
10090 "request_id": {
10091 "type": "string"
10092 }
10093 }
10094 },
911910095 "AuditLogDto": {
912010096 "type": "object",
912110097 "required": [
@@ -9498,16 +10474,41 @@
949810474 "type": [
949910475 "string",
950010476 "null"
9501 ]
9502 },
9503 "password": {
9504 "type": "string"
9505 },
9506 "role": {
9507 "$ref": "#/components/schemas/AdminRole"
9508 },
9509 "username": {
9510 "type": "string"
10477 ]
10478 },
10479 "password": {
10480 "type": "string"
10481 },
10482 "role": {
10483 "$ref": "#/components/schemas/AdminRole"
10484 },
10485 "username": {
10486 "type": "string"
10487 }
10488 }
10489 },
10490 "CreateRunnerTokenCmd": {
10491 "type": "object",
10492 "required": [
10493 "name"
10494 ],
10495 "properties": {
10496 "expires_in_days": {
10497 "type": [
10498 "integer",
10499 "null"
10500 ],
10501 "format": "int64"
10502 },
10503 "name": {
10504 "type": "string"
10505 },
10506 "space_id": {
10507 "type": [
10508 "string",
10509 "null"
10510 ],
10511 "format": "uuid"
951110512 }
951210513 }
951310514 },
@@ -9568,6 +10569,21 @@
956810569 }
956910570 }
957010571 },
10572 "CreatedRunnerTokenDto": {
10573 "type": "object",
10574 "required": [
10575 "token",
10576 "plaintext"
10577 ],
10578 "properties": {
10579 "plaintext": {
10580 "type": "string"
10581 },
10582 "token": {
10583 "$ref": "#/components/schemas/RunnerTokenDto"
10584 }
10585 }
10586 },
957110587 "DashboardDto": {
957210588 "type": "object",
957310589 "required": [
@@ -9879,6 +10895,57 @@
987910895 }
988010896 }
988110897 },
10898 "EnvironmentAdminDto": {
10899 "type": "object",
10900 "required": [
10901 "id",
10902 "space_id",
10903 "name",
10904 "required_labels",
10905 "secrets",
10906 "created_at",
10907 "updated_at"
10908 ],
10909 "properties": {
10910 "created_at": {
10911 "type": "string",
10912 "format": "date-time"
10913 },
10914 "description": {
10915 "type": [
10916 "string",
10917 "null"
10918 ]
10919 },
10920 "id": {
10921 "type": "string",
10922 "format": "uuid"
10923 },
10924 "name": {
10925 "type": "string"
10926 },
10927 "required_labels": {
10928 "type": "array",
10929 "items": {
10930 "type": "string"
10931 }
10932 },
10933 "secrets": {
10934 "type": "array",
10935 "items": {
10936 "type": "string"
10937 }
10938 },
10939 "space_id": {
10940 "type": "string",
10941 "format": "uuid"
10942 },
10943 "updated_at": {
10944 "type": "string",
10945 "format": "date-time"
10946 }
10947 }
10948 },
988210949 "FeaturesDto": {
988310950 "type": "object",
988410951 "required": [
@@ -10333,6 +11400,153 @@
1033311400 }
1033411401 }
1033511402 },
11403 "RunnerDto": {
11404 "type": "object",
11405 "required": [
11406 "id",
11407 "name",
11408 "labels",
11409 "capacity",
11410 "state",
11411 "active_jobs",
11412 "created_at"
11413 ],
11414 "properties": {
11415 "active_jobs": {
11416 "type": "integer",
11417 "format": "int64"
11418 },
11419 "capacity": {
11420 "type": "integer",
11421 "format": "int32"
11422 },
11423 "created_at": {
11424 "type": "string",
11425 "format": "date-time"
11426 },
11427 "id": {
11428 "type": "string",
11429 "format": "uuid"
11430 },
11431 "labels": {
11432 "type": "array",
11433 "items": {
11434 "type": "string"
11435 }
11436 },
11437 "last_seen_at": {
11438 "type": [
11439 "string",
11440 "null"
11441 ],
11442 "format": "date-time"
11443 },
11444 "name": {
11445 "type": "string"
11446 },
11447 "space_id": {
11448 "type": [
11449 "string",
11450 "null"
11451 ],
11452 "format": "uuid"
11453 },
11454 "state": {
11455 "type": "string"
11456 },
11457 "version": {
11458 "type": [
11459 "string",
11460 "null"
11461 ]
11462 }
11463 }
11464 },
11465 "RunnerPageDto": {
11466 "type": "object",
11467 "required": [
11468 "items",
11469 "total",
11470 "limit",
11471 "offset"
11472 ],
11473 "properties": {
11474 "items": {
11475 "type": "array",
11476 "items": {
11477 "$ref": "#/components/schemas/RunnerDto"
11478 }
11479 },
11480 "limit": {
11481 "type": "integer",
11482 "format": "int64",
11483 "minimum": 0
11484 },
11485 "offset": {
11486 "type": "integer",
11487 "format": "int64",
11488 "minimum": 0
11489 },
11490 "total": {
11491 "type": "integer",
11492 "format": "int64",
11493 "minimum": 0
11494 }
11495 }
11496 },
11497 "RunnerTokenDto": {
11498 "type": "object",
11499 "required": [
11500 "id",
11501 "name",
11502 "single_use",
11503 "created_at"
11504 ],
11505 "properties": {
11506 "created_at": {
11507 "type": "string",
11508 "format": "date-time"
11509 },
11510 "expires_at": {
11511 "type": [
11512 "string",
11513 "null"
11514 ],
11515 "format": "date-time"
11516 },
11517 "id": {
11518 "type": "string",
11519 "format": "uuid"
11520 },
11521 "name": {
11522 "type": "string"
11523 },
11524 "revoked_at": {
11525 "type": [
11526 "string",
11527 "null"
11528 ],
11529 "format": "date-time"
11530 },
11531 "single_use": {
11532 "type": "boolean"
11533 },
11534 "space_id": {
11535 "type": [
11536 "string",
11537 "null"
11538 ],
11539 "format": "uuid"
11540 },
11541 "used_at": {
11542 "type": [
11543 "string",
11544 "null"
11545 ],
11546 "format": "date-time"
11547 }
11548 }
11549 },
1033611550 "SearchConfigDto": {
1033711551 "type": "object",
1033811552 "required": [
@@ -10951,6 +12165,33 @@
1095112165 }
1095212166 }
1095312167 },
12168 "UpdateRunnerCmd": {
12169 "type": "object",
12170 "properties": {
12171 "capacity": {
12172 "type": [
12173 "integer",
12174 "null"
12175 ],
12176 "format": "int32"
12177 },
12178 "enabled": {
12179 "type": [
12180 "boolean",
12181 "null"
12182 ]
12183 },
12184 "labels": {
12185 "type": [
12186 "array",
12187 "null"
12188 ],
12189 "items": {
12190 "type": "string"
12191 }
12192 }
12193 }
12194 },
1095412195 "UpdateSpaceCmd": {
1095512196 "type": "object",
1095612197 "properties": {
@@ -11026,6 +12267,44 @@
1102612267 ]
1102712268 }
1102812269 }
12270 },
12271 "UpsertEnvironmentAdminCmd": {
12272 "type": "object",
12273 "required": [
12274 "name"
12275 ],
12276 "properties": {
12277 "description": {
12278 "type": [
12279 "string",
12280 "null"
12281 ]
12282 },
12283 "name": {
12284 "type": "string"
12285 },
12286 "required_labels": {
12287 "type": "array",
12288 "items": {
12289 "type": "string"
12290 }
12291 }
12292 }
12293 },
12294 "UpsertEnvironmentSecretCmd": {
12295 "type": "object",
12296 "required": [
12297 "name",
12298 "value"
12299 ],
12300 "properties": {
12301 "name": {
12302 "type": "string"
12303 },
12304 "value": {
12305 "type": "string"
12306 }
12307 }
1102912308 }
1103012309 },
1103112310 "securitySchemes": {
Modified app/adminhost/src/main.rs View app/adminhost/src/main.rs
@@ -91,10 +91,7 @@
9191 let router: Router = Router::new()
9292 .nest("/api/v1", api)
9393 .merge(health)
94 .merge(
95 utoipa_swagger_ui::SwaggerUi::new("/api/docs")
96 .url("/api/docs/openapi.json", doc),
97 )
94 .merge(utoipa_swagger_ui::SwaggerUi::new("/api/docs").url("/api/docs/openapi.json", doc))
9895 .fallback(spa_fallback)
9996 .layer(build_cors_layer(&cfg.admin))
10097 .layer(SessionLayer::new(session_store))
@@ -161,10 +158,7 @@
161158 {
162159 return (
163160 StatusCode::NOT_MODIFIED,
164 [
165 (header::CACHE_CONTROL, cache_control),
166 (header::ETAG, etag),
167 ],
161 [(header::CACHE_CONTROL, cache_control), (header::ETAG, etag)],
168162 )
169163 .into_response();
170164 }
@@ -227,4 +221,4 @@
227221 .split(',')
228222 .map(str::trim)
229223 .any(|candidate| candidate == "*" || strong(candidate) == strong(target))
230}
No newline at end of file
224}
Modified app/apihost/src/main.rs View app/apihost/src/main.rs
@@ -60,5 +60,3 @@
6060 info!("apihost shut down");
6161 Ok(())
6262}
63
64
Modified app/githost/src/main.rs View app/githost/src/main.rs
@@ -27,10 +27,13 @@
2727 let smart_svc = git::smart::service::SmartService::new(git_svc)
2828 .with_kv(Some(Arc::new(state.redis.clone())))
2929 .with_queue(Some(sync_queue))
30 .with_sync_settings(cfg.git.sync.clone());
30 .with_sync_settings(cfg.git.sync.clone())
31 .with_ci_event_topic(cfg.ci.enabled.then(|| cfg.ci.event_queue_topic.clone()));
3132 let ssh_svc = git::ssh::service::GitSshService::new(smart_svc.clone(), cfg.git.ssh.clone())
32 .with_sync_settings(cfg.git.sync.clone());
33 let smart_router = git::smart::handler::router(smart_svc).merge(export::health::router(state.clone()));
33 .with_sync_settings(cfg.git.sync.clone())
34 .with_ci_event_topic(cfg.ci.enabled.then(|| cfg.ci.event_queue_topic.clone()));
35 let smart_router =
36 git::smart::handler::router(smart_svc).merge(export::health::router(state.clone()));
3437
3538 let http_addr = SocketAddr::new(cfg.githost.http_bind_addr(), cfg.githost.http_port());
3639 let http_listener = TcpListener::bind(http_addr)
@@ -95,5 +98,3 @@
9598 info!("githost shut down");
9699 Ok(())
97100}
98
99
Modified app/im/src/jwt.rs View app/im/src/jwt.rs
@@ -5,22 +5,26 @@
55 use export::RedisConnection;
66 let mut conn = redis.connection_clone();
77 let result: Result<Option<String>, redis::RedisError> = match &mut conn {
8 RedisConnection::Standalone(c) => 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 RedisConnection::Cluster(c) => redis::cmd("SET")
17 .arg(format!("chat:jwt_used:{jti}"))
18 .arg("1")
19 .arg("NX")
20 .arg("EX")
21 .arg(ttl_secs.max(1))
22 .query_async(c)
23 .await,
8 RedisConnection::Standalone(c) => {
9 redis::cmd("SET")
10 .arg(format!("chat:jwt_used:{jti}"))
11 .arg("1")
12 .arg("NX")
13 .arg("EX")
14 .arg(ttl_secs.max(1))
15 .query_async(c)
16 .await
17 }
18 RedisConnection::Cluster(c) => {
19 redis::cmd("SET")
20 .arg(format!("chat:jwt_used:{jti}"))
21 .arg("1")
22 .arg("NX")
23 .arg("EX")
24 .arg(ttl_secs.max(1))
25 .query_async(c)
26 .await
27 }
2428 };
2529 matches!(result, Ok(Some(_)))
2630}
Modified app/mails/src/main.rs View app/mails/src/main.rs
@@ -48,7 +48,9 @@
4848 let router = export::health::router_with(None, redis);
4949 health_handle = Some(tokio::spawn(async move {
5050 let _ = axum::serve(listener, router)
51 .with_graceful_shutdown(async { let _ = health_rx.await; })
51 .with_graceful_shutdown(async {
52 let _ = health_rx.await;
53 })
5254 .await;
5355 }));
5456 }
Modified app/openapi/src/main.rs View app/openapi/src/main.rs
@@ -15,8 +15,8 @@
1515}
1616
1717fn check_file(path: &Path, json: &str) -> anyhow::Result<()> {
18 let existing = std::fs::read_to_string(path)
19 .with_context(|| format!("reading {}", path.display()))?;
18 let existing =
19 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
2020 if existing.trim_end() != json.trim_end() {
2121 eprintln!(
2222 "{} is out of date; run `cargo run -p openapi` to regenerate",
@@ -34,7 +34,8 @@
3434 })?;
3535 let admin = generate(|| {
3636 let doc = admin::openapi::api_doc();
37 doc.to_pretty_json().context("serializing admin openapi doc")
37 doc.to_pretty_json()
38 .context("serializing admin openapi doc")
3839 })?;
3940
4041 let crate_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
@@ -56,10 +57,6 @@
5657 std::fs::write(&web_path, web).with_context(|| format!("writing {}", web_path.display()))?;
5758 std::fs::write(&admin_path, admin)
5859 .with_context(|| format!("writing {}", admin_path.display()))?;
59 println!(
60 "wrote {} and {}",
61 web_path.display(),
62 admin_path.display()
63 );
60 println!("wrote {} and {}", web_path.display(), admin_path.display());
6461 Ok(())
65}
No newline at end of file
62}
Added app/runner/src/client.rs View app/runner/src/client.rs
@@ -0,0 +1,131 @@
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 mut request = self.http.post(self.url(path)).json(body);
48 if let Some(credential) = &self.credential {
49 request = request.bearer_auth(credential);
50 }
51 let response = request.send().await.context("sending runner request")?;
52 let status = response.status();
53 let text = response.text().await.unwrap_or_default();
54 if status != StatusCode::OK {
55 anyhow::bail!("runner request {path} failed: {status} {text}");
56 }
57 let envelope: serde_json::Value =
58 serde_json::from_str(&text).context("decoding response envelope")?;
59 let data = envelope
60 .get("data")
61 .cloned()
62 .ok_or_else(|| anyhow::anyhow!("response envelope has no data"))?;
63 serde_json::from_value(data).with_context(|| format!("decoding response data for {path}"))
64 }
65
66 pub async fn register(&self, request: &RegisterRequest) -> anyhow::Result<RegisterResponse> {
67 self.post("/register", request).await
68 }
69
70 pub async fn heartbeat(&self, request: &HeartbeatRequest) -> anyhow::Result<HeartbeatResponse> {
71 self.post("/heartbeat", request).await
72 }
73
74 pub async fn claim(&self, request: &ClaimRequest) -> anyhow::Result<ClaimResponse> {
75 self.post("/jobs/claim", request).await
76 }
77
78 pub async fn start(&self, job_id: &Uuid) -> anyhow::Result<Uuid> {
79 self.post(&format!("/jobs/{job_id}/start"), &serde_json::json!({}))
80 .await
81 }
82
83 pub async fn renew(&self, job_id: &Uuid) -> anyhow::Result<Uuid> {
84 self.post(&format!("/jobs/{job_id}/renew"), &serde_json::json!({}))
85 .await
86 }
87
88 pub async fn report_step(
89 &self,
90 job_id: Uuid,
91 index: i32,
92 report: &StepReport,
93 ) -> anyhow::Result<Uuid> {
94 self.post(&format!("/jobs/{job_id}/steps/{index}"), report)
95 .await
96 }
97
98 pub async fn upload_logs(
99 &self,
100 job_id: Uuid,
101 request: &LogChunksRequest,
102 ) -> anyhow::Result<Uuid> {
103 self.post(&format!("/jobs/{job_id}/logs"), request).await
104 }
105
106 pub async fn finish(&self, job_id: &Uuid, request: &JobFinishRequest) -> anyhow::Result<Uuid> {
107 self.post(&format!("/jobs/{job_id}/finish"), request).await
108 }
109
110 pub async fn init_artifact(
111 &self,
112 job_id: Uuid,
113 request: &ArtifactInitRequest,
114 ) -> anyhow::Result<ArtifactInitResponse> {
115 self.post(&format!("/jobs/{job_id}/artifacts/init"), request)
116 .await
117 }
118
119 pub async fn complete_artifact(
120 &self,
121 job_id: Uuid,
122 artifact_id: Uuid,
123 request: &ArtifactCompleteRequest,
124 ) -> anyhow::Result<Uuid> {
125 self.post(
126 &format!("/jobs/{job_id}/artifacts/{artifact_id}/complete"),
127 request,
128 )
129 .await
130 }
131}
Added app/runner/src/config.rs View app/runner/src/config.rs
@@ -0,0 +1,11 @@
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}
Added app/runner/src/executor.rs View app/runner/src/executor.rs
@@ -0,0 +1,592 @@
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;
10
11pub const MAX_STEP_OUTPUT_BYTES: usize = 4 * 1024 * 1024;
12
13#[derive(Debug)]
14pub enum JobOutcome {
15 Success {
16 exit_code: Option<i32>,
17 },
18 Failure {
19 exit_code: Option<i32>,
20 error: Option<String>,
21 },
22 Cancelled,
23 TimedOut,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum StepSignal {
28 Started,
29 Finished,
30}
31
32static CANCELLED: OnceLock<Mutex<HashMap<uuid::Uuid, ()>>> = OnceLock::new();
33
34fn cancelled_set() -> &'static Mutex<HashMap<uuid::Uuid, ()>> {
35 CANCELLED.get_or_init(|| Mutex::new(HashMap::new()))
36}
37
38pub fn request_cancel(job_ids: &[uuid::Uuid]) {
39 if let Ok(mut set) = cancelled_set().lock() {
40 for id in job_ids {
41 set.insert(*id, ());
42 }
43 }
44 #[cfg(unix)]
45 {
46 if let Ok(pids) = running_pids().lock()
47 && let Some(pid) = job_ids.iter().find_map(|id| pids.get(id).copied())
48 {
49 let _ = nix_kill_group(pid);
50 }
51 }
52}
53
54pub fn clear_cancel(job_id: &uuid::Uuid) {
55 if let Ok(mut set) = cancelled_set().lock() {
56 set.remove(job_id);
57 }
58}
59
60fn is_cancelled(job_id: &uuid::Uuid) -> bool {
61 cancelled_set()
62 .lock()
63 .map(|set| set.contains_key(job_id))
64 .unwrap_or(false)
65}
66
67static RUNNING_PIDS: OnceLock<Mutex<HashMap<uuid::Uuid, i32>>> = OnceLock::new();
68
69fn running_pids() -> &'static Mutex<HashMap<uuid::Uuid, i32>> {
70 RUNNING_PIDS.get_or_init(|| Mutex::new(HashMap::new()))
71}
72
73#[cfg(unix)]
74fn nix_kill_group(pid: i32) {
75 // TERM the whole process group so shell children die with the step.
76 unsafe {
77 libc_kill(-pid, 15);
78 }
79}
80
81#[cfg(unix)]
82unsafe fn libc_kill(pid: i32, sig: i32) {
83 unsafe extern "C" {
84 fn kill(pid: i32, sig: i32) -> i32;
85 }
86 unsafe {
87 kill(pid, sig);
88 }
89}
90
91#[derive(Clone)]
92pub struct StepHooks {
93 pub on_status: std::sync::Arc<dyn Fn(i32, StepSignal, Option<i32>) + Send + Sync>,
94 pub on_log: std::sync::Arc<dyn Fn(String) + Send + Sync>,
95}
96
97#[allow(clippy::too_many_arguments)]
98pub async fn execute_job(
99 cfg: &RunnerConfig,
100 payload: &JobPayload,
101 hooks: StepHooks,
102 on_artifact: std::sync::Arc<dyn Fn(String, String, i64, Vec<u8>) + Send + Sync>,
103 renew: std::sync::Arc<dyn Fn() + Send + Sync>,
104) -> JobOutcome {
105 let workspace = PathBuf::from(&cfg.workspace_root)
106 .join(payload.run_id.to_string())
107 .join(payload.job_id.to_string());
108 if let Err(error) = std::fs::create_dir_all(&workspace) {
109 return JobOutcome::Failure {
110 exit_code: None,
111 error: Some(format!("creating workspace failed: {error}")),
112 };
113 }
114
115 let repo_dir = workspace.join("repo");
116 if let Err(error) = prepare_workspace(cfg, payload, &workspace, &repo_dir, hooks.clone()).await
117 {
118 cleanup(cfg, &workspace);
119 return JobOutcome::Failure {
120 exit_code: None,
121 error: Some(error.to_string()),
122 };
123 }
124
125 if let Err(error) = download_needs_artifacts(payload, &repo_dir, hooks.clone()).await {
126 (hooks.on_log)(format!("artifact download failed: {error}\n"));
127 }
128
129 let mut exit_code = 0i32;
130 let mut failure: Option<String> = None;
131 let deadline =
132 std::time::Instant::now() + std::time::Duration::from_secs(payload.timeout_secs.max(1));
133
134 for step in &payload.steps {
135 if is_cancelled(&payload.job_id) {
136 (hooks.on_status)(step.index, StepSignal::Finished, None);
137 cleanup(cfg, &workspace);
138 return JobOutcome::Cancelled;
139 }
140 if std::time::Instant::now() >= deadline {
141 cleanup(cfg, &workspace);
142 return JobOutcome::TimedOut;
143 }
144 (hooks.on_status)(step.index, StepSignal::Started, None);
145 (hooks.on_log)(format!(
146 "::group:: step {} · {}\n",
147 step.index + 1,
148 step.name
149 ));
150 let result = run_step(cfg, payload, step, &repo_dir, hooks.clone(), deadline).await;
151 match result {
152 Ok(code) => {
153 (hooks.on_status)(step.index, StepSignal::Finished, Some(code));
154 (hooks.on_log)("::endgroup::\n".to_string());
155 if code != 0 {
156 exit_code = code;
157 failure = Some(format!("step `{}` exited with {code}", step.name));
158 break;
159 }
160 }
161 Err(StepFailure::Cancelled) => {
162 (hooks.on_status)(step.index, StepSignal::Finished, None);
163 cleanup(cfg, &workspace);
164 return JobOutcome::Cancelled;
165 }
166 Err(StepFailure::TimedOut) => {
167 (hooks.on_status)(step.index, StepSignal::Finished, None);
168 cleanup(cfg, &workspace);
169 return JobOutcome::TimedOut;
170 }
171 Err(StepFailure::Io(message)) => {
172 (hooks.on_status)(step.index, StepSignal::Finished, None);
173 (hooks.on_log)(format!("{message}\n"));
174 exit_code = 1;
175 failure = Some(message);
176 break;
177 }
178 }
179 (renew)();
180 }
181
182 collect_artifacts(cfg, payload, &repo_dir, Arc::clone(&on_artifact)).await;
183 cleanup(cfg, &workspace);
184
185 match failure {
186 None => JobOutcome::Success {
187 exit_code: Some(exit_code),
188 },
189 Some(error) => JobOutcome::Failure {
190 exit_code: Some(exit_code),
191 error: Some(error),
192 },
193 }
194}
195
196enum StepFailure {
197 Cancelled,
198 TimedOut,
199 Io(String),
200}
201
202async fn prepare_workspace(
203 cfg: &RunnerConfig,
204 payload: &JobPayload,
205 workspace: &Path,
206 repo_dir: &Path,
207 hooks: StepHooks,
208) -> anyhow::Result<()> {
209 (hooks.on_log)(format!(
210 "clone {} @ {}\n",
211 payload.clone_url, payload.commit_sha
212 ));
213 if repo_dir.exists() {
214 std::fs::remove_dir_all(repo_dir)?;
215 }
216 let auth_url = match &cfg.pat {
217 pat if !pat.trim().is_empty() => inject_pat(&payload.clone_url, pat),
218 _ => payload.clone_url.clone(),
219 };
220 let mut command = Command::new(&cfg.git_binary);
221 command
222 .arg("clone")
223 .arg("--no-tags")
224 .arg(&auth_url)
225 .arg(repo_dir)
226 .stdout(Stdio::piped())
227 .stderr(Stdio::piped())
228 .current_dir(workspace);
229 run_streaming(command, None, &cfg.clone_timeout_secs, hooks.clone()).await?;
230 let mut checkout = Command::new(&cfg.git_binary);
231 checkout
232 .arg("-C")
233 .arg(repo_dir)
234 .arg("checkout")
235 .arg("--force")
236 .arg(&payload.commit_sha)
237 .stdout(Stdio::piped())
238 .stderr(Stdio::piped());
239 run_streaming(checkout, None, &cfg.clone_timeout_secs, hooks.clone()).await?;
240 Ok(())
241}
242
243fn inject_pat(url: &str, pat: &str) -> String {
244 match url.split_once("://") {
245 Some((scheme, rest)) => format!("{scheme}://{pat}@{rest}"),
246 None => url.to_string(),
247 }
248}
249
250async fn download_needs_artifacts(
251 payload: &JobPayload,
252 repo_dir: &Path,
253 hooks: StepHooks,
254) -> anyhow::Result<()> {
255 for artifact in &payload.needs_artifacts {
256 let response = reqwest::get(&artifact.download_url).await?;
257 if !response.status().is_success() {
258 anyhow::bail!("artifact {} returned {}", artifact.name, response.status());
259 }
260 let bytes = response.bytes().await?;
261 if let Some(expected) = &artifact.sha256 {
262 let actual = sha256_hex(&bytes);
263 if &actual != expected {
264 anyhow::bail!("artifact {} checksum mismatch", artifact.name);
265 }
266 }
267 let dir = repo_dir.join(".gitdata-artifacts");
268 std::fs::create_dir_all(&dir)?;
269 std::fs::write(dir.join(&artifact.name), &bytes)?;
270 (hooks.on_log)(format!(
271 "downloaded artifact {} ({} bytes) into .gitdata-artifacts/\n",
272 artifact.name,
273 bytes.len()
274 ));
275 }
276 Ok(())
277}
278
279async fn run_step(
280 cfg: &RunnerConfig,
281 payload: &JobPayload,
282 step: &StepPayload,
283 repo_dir: &Path,
284 hooks: StepHooks,
285 job_deadline: std::time::Instant,
286) -> Result<i32, StepFailure> {
287 let workdir = match &step.working_directory {
288 Some(dir) if !dir.trim().is_empty() => repo_dir.join(dir.trim()),
289 _ => repo_dir.to_path_buf(),
290 };
291 if !workdir.starts_with(repo_dir) {
292 return Err(StepFailure::Io(
293 "working-directory escapes the workspace".to_string(),
294 ));
295 }
296 let shell = step
297 .shell
298 .clone()
299 .unwrap_or_else(|| cfg.default_shell.clone());
300 let mut env: HashMap<String, String> = payload.env.clone().into_iter().collect();
301 env.extend(step.env.clone());
302 // Secrets ride on the job payload and are layered under the workflow/step
303 // env so a stored secret cannot be shadowed by a plaintext variable.
304 for (key, value) in &payload.secrets {
305 env.insert(key.clone(), value.clone());
306 }
307 if payload.allow_git_token && !cfg.pat.trim().is_empty() {
308 env.insert("GITDATA_TOKEN".to_string(), cfg.pat.clone());
309 }
310
311 let timeout = step
312 .timeout_secs
313 .map(|secs| std::time::Duration::from_secs(secs.max(1)))
314 .unwrap_or_else(|| {
315 job_deadline
316 .saturating_duration_since(std::time::Instant::now())
317 .max(std::time::Duration::from_secs(1))
318 });
319
320 let mut command = if let Some(image) = &payload.container {
321 if !cfg.docker_enabled {
322 return Err(StepFailure::Io(
323 "job requires a container but docker is disabled on this runner".to_string(),
324 ));
325 }
326 let mut cmd = Command::new(&cfg.docker_binary);
327 cmd.arg("run")
328 .arg("--rm")
329 .arg("-v")
330 .arg(format!("{}:/workspace", repo_dir.display()))
331 .arg("-w")
332 .arg(match &step.working_directory {
333 Some(dir) if !dir.trim().is_empty() => {
334 format!("/workspace/{}", dir.trim().trim_start_matches('/'))
335 }
336 _ => "/workspace".to_string(),
337 });
338 for (key, value) in &env {
339 cmd.arg("-e").arg(format!("{key}={value}"));
340 }
341 cmd.arg(image).arg(&shell).arg("-c").arg(&step.run);
342 cmd
343 } else {
344 let mut cmd = Command::new(&shell);
345 cmd.arg("-c").arg(&step.run).current_dir(&workdir);
346 for (key, value) in &env {
347 cmd.env(key, value);
348 }
349 cmd
350 };
351
352 command
353 .stdout(Stdio::piped())
354 .stderr(Stdio::piped())
355 .stdin(Stdio::null());
356 #[cfg(unix)]
357 {
358 use std::os::unix::process::CommandExt;
359 command.process_group(0);
360 }
361
362 let mut child = command
363 .spawn()
364 .map_err(|e| StepFailure::Io(format!("spawning step failed: {e}")))?;
365 let pid = child.id().map(|p| p as i32);
366 if let Some(pid) = pid
367 && let Ok(mut pids) = running_pids().lock()
368 {
369 pids.insert(payload.job_id, pid);
370 }
371
372 let stdout = child.stdout.take();
373 let stderr = child.stderr.take();
374 let stdout_hook = Arc::clone(&hooks.on_log);
375 let stdout_task = stdout.map(|stdout| {
376 tokio::spawn(async move {
377 let mut lines = BufReader::new(stdout).lines();
378 while let Ok(Some(line)) = lines.next_line().await {
379 stdout_hook(format!("{line}\n"));
380 }
381 })
382 });
383 let stderr_hook = Arc::clone(&hooks.on_log);
384 let stderr_task = stderr.map(|stderr| {
385 tokio::spawn(async move {
386 let mut lines = BufReader::new(stderr).lines();
387 while let Ok(Some(line)) = lines.next_line().await {
388 stderr_hook(format!("{line}\n"));
389 }
390 })
391 });
392 // Race the wait against a cancel poll so `docker run` / host steps stop
393 // promptly on every platform, not only where SIGTERM reaches a group.
394 let wait: Result<Result<std::process::ExitStatus, std::io::Error>, StepFailure> = tokio::select! {
395 result = tokio::time::timeout(timeout, child.wait()) => result.map_err(|_| StepFailure::TimedOut),
396 _ = async {
397 while !is_cancelled(&payload.job_id) {
398 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
399 }
400 } => {
401 let _ = child.kill().await;
402 let _ = child.wait().await;
403 Err(StepFailure::Cancelled)
404 }
405 };
406 if let Some(task) = stdout_task {
407 let _ = task.await;
408 }
409 if let Some(task) = stderr_task {
410 let _ = task.await;
411 }
412 if let Some(_pid) = pid
413 && let Ok(mut pids) = running_pids().lock()
414 {
415 pids.remove(&payload.job_id);
416 }
417
418 match wait {
419 Ok(Ok(status)) => {
420 if is_cancelled(&payload.job_id) {
421 return Err(StepFailure::Cancelled);
422 }
423 Ok(status.code().unwrap_or(-1))
424 }
425 Ok(Err(error)) => Err(StepFailure::Io(format!("step wait failed: {error}"))),
426 Err(StepFailure::Cancelled) => {
427 let _ = child.kill().await;
428 Err(StepFailure::Cancelled)
429 }
430 Err(StepFailure::TimedOut) => {
431 let _ = child.kill().await;
432 if is_cancelled(&payload.job_id) {
433 Err(StepFailure::Cancelled)
434 } else {
435 Err(StepFailure::TimedOut)
436 }
437 }
438 Err(StepFailure::Io(message)) => Err(StepFailure::Io(message)),
439 }
440}
441
442async fn run_streaming(
443 mut command: Command,
444 stdin: Option<Vec<u8>>,
445 timeout_secs: &u64,
446 hooks: StepHooks,
447) -> anyhow::Result<()> {
448 let _ = stdin;
449 let mut child = command.spawn()?;
450 let stdout = child.stdout.take();
451 let stderr = child.stderr.take();
452 let stdout_hook = Arc::clone(&hooks.on_log);
453 let stdout_task = stdout.map(|stdout| {
454 tokio::spawn(async move {
455 let mut lines = BufReader::new(stdout).lines();
456 while let Ok(Some(line)) = lines.next_line().await {
457 stdout_hook(format!("{line}\n"));
458 }
459 })
460 });
461 let stderr_hook = Arc::clone(&hooks.on_log);
462 let stderr_task = stderr.map(|stderr| {
463 tokio::spawn(async move {
464 let mut lines = BufReader::new(stderr).lines();
465 while let Ok(Some(line)) = lines.next_line().await {
466 stderr_hook(format!("{line}\n"));
467 }
468 })
469 });
470 let status = tokio::time::timeout(
471 std::time::Duration::from_secs((*timeout_secs).max(1)),
472 child.wait(),
473 )
474 .await;
475 if let Some(task) = stdout_task {
476 let _ = task.await;
477 }
478 if let Some(task) = stderr_task {
479 let _ = task.await;
480 }
481 match status {
482 Ok(status) => {
483 let status = status?;
484 if status.success() {
485 Ok(())
486 } else {
487 anyhow::bail!("command exited with {:?}", status.code())
488 }
489 }
490 Err(_) => {
491 let _ = child.kill().await;
492 anyhow::bail!("command timed out")
493 }
494 }
495}
496
497async fn collect_artifacts(
498 _cfg: &RunnerConfig,
499 payload: &JobPayload,
500 repo_dir: &Path,
501 on_artifact: std::sync::Arc<dyn Fn(String, String, i64, Vec<u8>) + Send + Sync>,
502) {
503 for spec in &payload.artifacts {
504 let base = repo_dir.join(&spec.path);
505 let mut files: Vec<(String, Vec<u8>)> = Vec::new();
506 if base.is_file() {
507 if let Ok(bytes) = std::fs::read(&base) {
508 files.push((spec.path.clone(), bytes));
509 }
510 } else if base.is_dir() {
511 collect_dir(&base, &spec.path, &mut files);
512 } else if let Ok(set) = globset::Glob::new(&spec.path) {
513 let matcher = set.compile_matcher();
514 walk(repo_dir, repo_dir, &mut |rel, path| {
515 if matcher.is_match(rel)
516 && let Ok(bytes) = std::fs::read(path)
517 {
518 files.push((rel.to_string(), bytes));
519 }
520 });
521 }
522 if files.is_empty() {
523 continue;
524 }
525 // A spec maps to a single artifact; directory/glob payloads are
526 // tar-less concatenations preserved per file under the artifact name.
527 if files.len() == 1 {
528 let (_, bytes) = files.remove(0);
529 let size = bytes.len() as i64;
530 on_artifact(spec.name.clone(), spec.path.clone(), size, bytes);
531 } else {
532 let mut bytes = Vec::new();
533 for (rel, content) in files {
534 bytes.extend_from_slice(format!("--- {rel} ---\n").as_bytes());
535 bytes.extend_from_slice(&content);
536 bytes.push(b'\n');
537 }
538 let size = bytes.len() as i64;
539 on_artifact(spec.name.clone(), spec.path.clone(), size, bytes);
540 }
541 }
542}
543
544fn collect_dir(dir: &Path, prefix: &str, out: &mut Vec<(String, Vec<u8>)>) {
545 walk(dir, dir, &mut |rel, path| {
546 if path.is_file()
547 && let Ok(bytes) = std::fs::read(path)
548 {
549 out.push((format!("{prefix}/{rel}"), bytes));
550 }
551 });
552}
553
554fn walk(root: &Path, dir: &Path, visit: &mut dyn FnMut(&str, &Path)) {
555 let Ok(entries) = std::fs::read_dir(dir) else {
556 return;
557 };
558 for entry in entries.flatten() {
559 let path = entry.path();
560 if path.is_dir() {
561 walk(root, &path, visit);
562 } else if let Ok(rel) = path.strip_prefix(root) {
563 visit(&rel.to_string_lossy(), &path);
564 }
565 }
566}
567
568fn cleanup(cfg: &RunnerConfig, workspace: &Path) {
569 if cfg.keep_workspace {
570 return;
571 }
572 let _ = std::fs::remove_dir_all(workspace);
573}
574
575pub async fn upload_file(url: &str, bytes: Vec<u8>) -> anyhow::Result<()> {
576 let response = reqwest::Client::new().put(url).body(bytes).send().await?;
577 if !response.status().is_success() {
578 anyhow::bail!("upload failed with {}", response.status());
579 }
580 Ok(())
581}
582
583#[must_use]
584pub fn sha256_hex(bytes: &[u8]) -> String {
585 use sha2::{Digest, Sha256};
586 let digest = Sha256::digest(bytes);
587 let mut out = String::with_capacity(digest.len() * 2);
588 for byte in digest {
589 out.push_str(&format!("{byte:02x}"));
590 }
591 out
592}
Added app/runner/src/lib.rs View app/runner/src/lib.rs
@@ -0,0 +1,4 @@
1pub mod client;
2pub mod config;
3pub mod executor;
4pub mod state;
Added app/runner/src/main.rs View app/runner/src/main.rs
@@ -0,0 +1,287 @@
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::{info, warn};
11
12#[tokio::main]
13async fn main() -> anyhow::Result<()> {
14 init_tracing();
15
16 let cfg = config::runner::RunnerConfig::load().context("loading runner configuration")?;
17 if cfg.registration_token.trim().is_empty() && !credential_path(&cfg).exists() {
18 bail!("runner needs APP_RUNNER__REGISTRATION_TOKEN or an existing credential file");
19 }
20
21 std::fs::create_dir_all(&cfg.workspace_root)
22 .with_context(|| format!("creating workspace root {}", cfg.workspace_root))?;
23
24 let client = RunnerClient::new(&cfg)?;
25 let credential = match load_credential(&cfg)? {
26 Some(credential) => credential,
27 None => {
28 info!(server = %cfg.server_url, "registering runner");
29 let response = client
30 .register(&RegisterRequest {
31 token: cfg.registration_token.clone(),
32 name: cfg.resolved_name(),
33 labels: cfg.labels.clone(),
34 capacity: cfg.capacity,
35 version: Some(env!("CARGO_PKG_VERSION").to_string()),
36 })
37 .await
38 .context("registering runner")?;
39 if response.protocol_version != ci_protocol::PROTOCOL_VERSION {
40 bail!(
41 "runner protocol mismatch: server {} / runner {}",
42 response.protocol_version,
43 ci_protocol::PROTOCOL_VERSION
44 );
45 }
46 store_credential(&cfg, &response.credential)?;
47 info!(runner_id = %response.runner_id, "runner registered");
48 response.credential
49 }
50 };
51 let client = client.with_credential(credential);
52
53 let active = std::sync::Arc::new(std::sync::Mutex::new(
54 std::collections::HashSet::<uuid::Uuid>::new(),
55 ));
56 let heartbeat_client = client.clone();
57 let heartbeat_active = active.clone();
58 let heartbeat_interval = cfg.heartbeat_interval_secs.max(1);
59 let heartbeat_task = tokio::spawn(async move {
60 let mut ticker = tokio::time::interval(std::time::Duration::from_secs(heartbeat_interval));
61 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
62 loop {
63 ticker.tick().await;
64 let running: Vec<uuid::Uuid> = heartbeat_active
65 .lock()
66 .map(|set| set.iter().copied().collect())
67 .unwrap_or_default();
68 match heartbeat_client
69 .heartbeat(&HeartbeatRequest {
70 running_job_ids: running,
71 })
72 .await
73 {
74 Ok(response) => {
75 if !response.cancel.is_empty() {
76 warn!(jobs = ?response.cancel, "server requested job cancellation");
77 runner::executor::request_cancel(&response.cancel);
78 }
79 }
80 Err(error) => warn!(%error, "heartbeat failed"),
81 }
82 }
83 });
84
85 info!(server = %cfg.server_url, "runner polling for jobs");
86 loop {
87 let payload = match client.claim(&ClaimRequest { wait_secs: None }).await {
88 Ok(response) => match response.job {
89 Some(job) => job,
90 None => continue,
91 },
92 Err(error) => {
93 warn!(%error, "claim failed; backing off");
94 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
95 continue;
96 }
97 };
98 info!(job_id = %payload.job_id, job = %payload.job_name, "job received");
99 if let Ok(mut set) = active.lock() {
100 set.insert(payload.job_id);
101 }
102 let outcome = run_job(&client, &cfg, &payload).await;
103 if let Ok(mut set) = active.lock() {
104 set.remove(&payload.job_id);
105 }
106 let finish = match outcome {
107 JobOutcome::Success { exit_code } => JobFinishRequest {
108 state: JobFinishState::Success,
109 exit_code,
110 error: None,
111 },
112 JobOutcome::Failure { exit_code, error } => JobFinishRequest {
113 state: JobFinishState::Failure,
114 exit_code,
115 error,
116 },
117 JobOutcome::Cancelled => JobFinishRequest {
118 state: JobFinishState::Cancelled,
119 exit_code: None,
120 error: Some("cancelled".to_string()),
121 },
122 JobOutcome::TimedOut => JobFinishRequest {
123 state: JobFinishState::TimedOut,
124 exit_code: None,
125 error: Some("job timed out".to_string()),
126 },
127 };
128 if let Err(error) = client.finish(&payload.job_id, &finish).await {
129 warn!(%error, "job finish report failed");
130 }
131 runner::executor::clear_cancel(&payload.job_id);
132 if cfg.once {
133 info!("runner configured for a single job; exiting");
134 break;
135 }
136 }
137
138 heartbeat_task.abort();
139 Ok(())
140}
141
142async fn run_job(
143 client: &RunnerClient,
144 cfg: &config::runner::RunnerConfig,
145 payload: &JobPayload,
146) -> JobOutcome {
147 if let Err(error) = client.start(&payload.job_id).await {
148 warn!(%error, "job start report failed");
149 }
150
151 let seq = std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0));
152 let log_client = client.clone();
153 let log_job = payload.job_id;
154 // Mask secret values runner-side before they ever leave the machine; the
155 // scheduler redacts again on ingest.
156 let log_secrets: Vec<String> = payload.secrets.values().cloned().collect();
157 let on_log = {
158 let seq = std::sync::Arc::clone(&seq);
159 let secrets = log_secrets.clone();
160 move |text: String| {
161 let client = log_client.clone();
162 let job_id = log_job;
163 let text = mask_secrets(text, &secrets);
164 let num = seq.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
165 tokio::spawn(async move {
166 let _ = client
167 .upload_logs(
168 job_id,
169 &LogChunksRequest {
170 chunks: vec![LogChunk { seq: num, text }],
171 },
172 )
173 .await;
174 });
175 }
176 };
177
178 let step_client = client.clone();
179 let step_job = payload.job_id;
180 let on_status =
181 move |index: i32, signal: runner::executor::StepSignal, exit_code: Option<i32>| {
182 let client = step_client.clone();
183 let job_id = step_job;
184 tokio::spawn(async move {
185 let report = StepReport {
186 state: match signal {
187 runner::executor::StepSignal::Started => StepReportState::Started,
188 runner::executor::StepSignal::Finished => StepReportState::Finished,
189 },
190 exit_code,
191 name: None,
192 shell: None,
193 working_directory: None,
194 script: None,
195 timeout_secs: None,
196 started_at: (signal == runner::executor::StepSignal::Started)
197 .then(time::OffsetDateTime::now_utc),
198 finished_at: (signal == runner::executor::StepSignal::Finished)
199 .then(time::OffsetDateTime::now_utc),
200 };
201 let _ = client.report_step(job_id, index, &report).await;
202 });
203 };
204
205 let artifact_client = client.clone();
206 let artifact_job = payload.job_id;
207 let on_artifact = move |name: String, path: String, size: i64, bytes: Vec<u8>| {
208 let client = artifact_client.clone();
209 let job_id = artifact_job;
210 tokio::spawn(async move {
211 let sha256 = runner::executor::sha256_hex(&bytes);
212 let init = match client
213 .init_artifact(
214 job_id,
215 &ArtifactInitRequest {
216 name: name.clone(),
217 size,
218 sha256: Some(sha256.clone()),
219 },
220 )
221 .await
222 {
223 Ok(init) => init,
224 Err(error) => {
225 warn!(%error, %name, "artifact init failed");
226 return;
227 }
228 };
229 if let Err(error) = runner::executor::upload_file(&init.upload_url, bytes).await {
230 warn!(%error, %name, "artifact upload failed");
231 return;
232 }
233 let _ = path;
234 if let Err(error) = client
235 .complete_artifact(
236 job_id,
237 init.artifact_id,
238 &ArtifactCompleteRequest {
239 size,
240 sha256: Some(sha256),
241 },
242 )
243 .await
244 {
245 warn!(%error, %name, "artifact completion failed");
246 }
247 });
248 };
249
250 let renew_client = client.clone();
251 let renew_job = payload.job_id;
252 let renew = move || {
253 let client = renew_client.clone();
254 let job_id = renew_job;
255 tokio::spawn(async move {
256 let _ = client.renew(&job_id).await;
257 });
258 };
259
260 let hooks = StepHooks {
261 on_status: std::sync::Arc::new(on_status),
262 on_log: std::sync::Arc::new(on_log),
263 };
264 execute_job(
265 cfg,
266 payload,
267 hooks,
268 std::sync::Arc::new(on_artifact),
269 std::sync::Arc::new(renew),
270 )
271 .await
272}
273
274fn mask_secrets(mut text: String, secrets: &[String]) -> String {
275 for secret in secrets {
276 if secret.len() >= 4 && text.contains(secret.as_str()) {
277 text = text.replace(secret.as_str(), "***");
278 }
279 }
280 text
281}
282
283fn init_tracing() {
284 use tracing_subscriber::EnvFilter;
285 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
286 tracing_subscriber::fmt().with_env_filter(filter).init();
287}
Added app/runner/src/state.rs View app/runner/src/state.rs
@@ -0,0 +1,45 @@
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}
Added app/runner/Cargo.toml View app/runner/Cargo.toml
@@ -0,0 +1,32 @@
1[package]
2name = "runner"
3version.workspace = true
4edition.workspace = true
5publish = false
6
7[dependencies]
8ci-protocol = { workspace = true }
9config = { workspace = true }
10serde = { workspace = true, features = ["derive"] }
11serde_json = { workspace = true }
12tokio = { workspace = true, features = ["signal", "macros", "rt-multi-thread", "time", "sync", "process", "fs"] }
13tracing = { workspace = true }
14tracing-subscriber = { workspace = true }
15anyhow = { workspace = true }
16reqwest = { workspace = true }
17uuid = { workspace = true }
18time = { workspace = true }
19sha2 = { workspace = true }
20base64 = { workspace = true }
21globset = { workspace = true }
22figment = { workspace = true }
23dotenvy = { workspace = true }
24
25
26[lib]
27name = "runner"
28path = "src/lib.rs"
29
30[[bin]]
31name = "runner"
32path = "src/main.rs"
Added app/webhook/src/main.rs View app/webhook/src/main.rs
@@ -0,0 +1,177 @@
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::{info, warn};
11
12#[tokio::main]
13async fn main() -> anyhow::Result<()> {
14 export::telemetry::init_tracing();
15
16 let cfg = WebhookServiceConfig::load().context("loading configuration")?;
17 if !cfg.webhook.enabled() {
18 info!("webhook is disabled by config; exiting");
19 return Ok(());
20 }
21 if !cfg.ci.enabled {
22 info!("ci is disabled by config; webhook exits without scheduler");
23 return Ok(());
24 }
25
26 info!("connecting to database / redis / storage");
27 let state = export::AppState::with_config(cfg.clone().into_app_config())
28 .await
29 .context("building AppState")?;
30 let service = CiService::new(state);
31 info!("dependencies ready");
32
33 let cancel = CancellationToken::new();
34
35 let consumer = tokio::spawn(consume_events(service.clone(), cancel.clone()));
36 let scheduler = tokio::spawn(run_scheduler(
37 service.clone(),
38 cfg.ci.scheduler_interval_secs,
39 cancel.clone(),
40 ));
41
42 let addr = SocketAddr::new(
43 cfg.webhook
44 .bind_addr()
45 .parse()
46 .context("webhook bind addr")?,
47 cfg.webhook.port(),
48 );
49 let listener = TcpListener::bind(addr)
50 .await
51 .with_context(|| format!("binding webhook {addr}"))?;
52 let runner_routes = ci::routes::runner_router().with_state(service.clone());
53 let router: Router =
54 Router::new()
55 .nest("/api/v1", runner_routes)
56 .merge(export::health::router_with(
57 Some(service.state.database.clone()),
58 service.state.redis.clone(),
59 ));
60 info!(%addr, "webhook scheduler listening");
61
62 let server_cancel = cancel.clone();
63 axum::serve(listener, router.into_make_service())
64 .with_graceful_shutdown(async move { server_cancel.cancelled().await })
65 .await
66 .context("webhook server error")?;
67
68 cancel.cancel();
69 let _ = consumer.await;
70 let _ = scheduler.await;
71 info!("webhook shut down");
72 Ok(())
73}
74
75async fn consume_events(service: CiService, cancel: CancellationToken) {
76 use ci::events::{decode_event, publish_event_to_dlq};
77 use git::sync::queue::SyncQueue;
78
79 let topic = service.config().ci.event_queue_topic.clone();
80 let dlq = service.config().ci.event_dlq_topic.clone();
81 // BRPOP blocks longer than the Redis request timeout, so the consumer
82 // needs its own dedicated connection (same as the sync worker).
83 let queue = match SyncQueue::with_consumer_conns(service.state.redis.clone(), 1).await {
84 Ok(queue) => queue,
85 Err(error) => {
86 warn!(%error, "failed to open ci event consumer connection");
87 return;
88 }
89 };
90 let mut attempts: std::collections::HashMap<uuid::Uuid, u32> = std::collections::HashMap::new();
91 loop {
92 let payload = tokio::select! {
93 _ = cancel.cancelled() => break,
94 res = queue.consume(&topic, 5, 0) => match res {
95 Ok(Some(payload)) => payload,
96 Ok(None) => continue,
97 Err(error) => {
98 warn!(%error, "event consume failed");
99 tokio::time::sleep(Duration::from_secs(2)).await;
100 continue;
101 }
102 },
103 };
104 let event = match decode_event(&payload) {
105 Ok(event) => event,
106 Err(error) => {
107 warn!(%error, "dropping malformed ci event");
108 let _ = publish_event_to_dlq(&service.state.redis, &dlq, &payload).await;
109 continue;
110 }
111 };
112 let event_id = match &event {
113 ci_protocol::events::CiEvent::Push(push) => push.id,
114 ci_protocol::events::CiEvent::PullRequest(pr) => pr.id,
115 };
116 match service.handle_event(&event).await {
117 Ok(created) => {
118 attempts.remove(&event_id);
119 if !created.is_empty() {
120 info!(event = %event_id, runs = created.len(), "ci event produced runs");
121 }
122 }
123 Err(error) => {
124 let count = attempts.entry(event_id).or_insert(0);
125 *count += 1;
126 if *count > 5 {
127 attempts.remove(&event_id);
128 warn!(%error, event = %event_id, "ci event failed permanently; sending to DLQ");
129 let _ = publish_event_to_dlq(&service.state.redis, &dlq, &payload).await;
130 } else {
131 warn!(%error, event = %event_id, attempt = *count, "ci event failed; retrying");
132 tokio::time::sleep(Duration::from_secs(2 * u64::from(*count))).await;
133 let _ = queue.publish_raw(&topic, None, &payload).await;
134 }
135 }
136 }
137 }
138 info!("event consumer stopped");
139}
140
141async fn run_scheduler(service: CiService, interval_secs: u64, cancel: CancellationToken) {
142 let interval = Duration::from_secs(interval_secs.max(1));
143 let mut ticker = tokio::time::interval(interval);
144 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
145 let mut ticks: u64 = 0;
146 loop {
147 tokio::select! {
148 _ = cancel.cancelled() => break,
149 _ = ticker.tick() => {}
150 }
151 if let Err(error) = service.advance_jobs().await {
152 warn!(%error, "advance jobs failed");
153 }
154 if let Err(error) = service.enforce_concurrency().await {
155 warn!(%error, "concurrency enforcement failed");
156 }
157 if let Err(error) = service.expire_leases().await {
158 warn!(%error, "lease expiry failed");
159 }
160 if let Err(error) = service.apply_cancellations().await {
161 warn!(%error, "cancellation sweep failed");
162 }
163 if let Err(error) = service.finalize_runs().await {
164 warn!(%error, "run finalization failed");
165 }
166 if let Err(error) = service.deliver_pending_repo_webhooks().await {
167 warn!(%error, "outbound webhook delivery failed");
168 }
169 ticks = ticks.wrapping_add(1);
170 if ticks % 3600 == 0
171 && let Err(error) = service.prune_expired().await
172 {
173 warn!(%error, "retention prune failed");
174 }
175 }
176 info!("scheduler stopped");
177}
Added app/webhook/Cargo.toml View app/webhook/Cargo.toml
@@ -0,0 +1,26 @@
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 }
Added chart/templates/runner.yaml View chart/templates/runner.yaml
@@ -0,0 +1,77 @@
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 containers:
28 - name: runner
29 image: {{ include "gitdata.image" . }}
30 imagePullPolicy: {{ .Values.image.pullPolicy }}
31 args: ["runner"]
32 env:
33 - name: APP_RUNNER__SERVER_URL
34 value: {{ default (printf "http://%s-webhook:%d" (include "gitdata.fullname" .) .Values.webhook.port) .Values.runner.serverUrl | quote }}
35 - name: APP_RUNNER__REGISTRATION_TOKEN
36 valueFrom:
37 secretKeyRef:
38 name: {{ include "gitdata.secretName" . }}
39 key: APP_RUNNER__REGISTRATION_TOKEN
40 optional: true
41 - name: APP_RUNNER__NAME
42 valueFrom:
43 fieldRef:
44 fieldPath: metadata.name
45 - name: APP_RUNNER__LABELS
46 value: {{ .Values.runner.labels | toJson | quote }}
47 - name: APP_RUNNER__CAPACITY
48 value: {{ .Values.runner.capacity | quote }}
49 - name: APP_RUNNER__WORKSPACE_ROOT
50 value: /var/lib/gitdata-runner
51 - name: APP_RUNNER__CREDENTIAL_PATH
52 value: /var/lib/gitdata-runner/credential
53 - name: APP_RUNNER__DOCKER_ENABLED
54 value: {{ .Values.runner.dockerEnabled | quote }}
55 {{- with .Values.runner.extraEnv }}
56 {{- toYaml . | nindent 12 }}
57 {{- end }}
58 securityContext:
59 {{- toYaml .Values.securityContext . | nindent 12 }}
60 resources:
61 {{- toYaml .Values.runner.resources | nindent 12 }}
62 volumeMounts:
63 - name: workspace
64 mountPath: /var/lib/gitdata-runner
65 {{- if .Values.runner.dockerEnabled }}
66 - name: docker-sock
67 mountPath: /var/run/docker.sock
68 {{- end }}
69 volumes:
70 - name: workspace
71 emptyDir: {}
72 {{- if .Values.runner.dockerEnabled }}
73 - name: docker-sock
74 hostPath:
75 path: /var/run/docker.sock
76 {{- end }}
77{{- end }}
Added chart/templates/webhook.yaml View chart/templates/webhook.yaml
@@ -0,0 +1,132 @@
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 {{- with .Values.webhook.ingress.annotations }}
107 annotations:
108 {{- toYaml . | nindent 4 }}
109 {{- end }}
110spec:
111 {{- with .Values.webhook.ingress.className }}
112 ingressClassName: {{ . }}
113 {{- end }}
114 {{- if .Values.webhook.ingress.tls.enabled }}
115 tls:
116 - hosts:
117 - {{ .Values.webhook.ingress.host | quote }}
118 secretName: {{ default (printf "%s-webhook-tls" (include "gitdata.fullname" .)) .Values.webhook.ingress.tls.secretName }}
119 {{- end }}
120 rules:
121 - host: {{ .Values.webhook.ingress.host | quote }}
122 http:
123 paths:
124 - path: /
125 pathType: Prefix
126 backend:
127 service:
128 name: {{ printf "%s-webhook" (include "gitdata.fullname" .) }}
129 port:
130 name: http
131{{- end }}
132{{- end }}
Modified chart/values.yaml View chart/values.yaml
@@ -58,6 +58,7 @@
5858 APP_IMPORT__TOKEN_SECRET: "" # >=32 字节,加密导入任务里的源站令牌
5959 APP_ADMIN__SEED_USERNAME: "" # 首次启动种子管理员账号(admin_user 为空时创建)
6060 APP_ADMIN__SEED_PASSWORD: "" # 种子管理员口令,首次登录强制修改
61 APP_CI__SECRET_ENCRYPTION_KEY: "" # base64 >=32 字节,CI secrets / webhook 加密密钥
6162
6263podSecurityContext:
6364 fsGroup: 10001
@@ -221,3 +222,34 @@
221222metrics:
222223 # OTLP 推送(无 scrape 端点,见 §13)
223224 otlpEndpoint: ""
225
226webhook:
227 enabled: true
228 replicaCount: 1
229 port: 3005
230 resources:
231 requests: { cpu: 100m, memory: 128Mi }
232 limits: { memory: 512Mi }
233 extraEnv: []
234 # 外部 runner 通过该域名接入调度中心;留空则仅集群内 Service 可达
235 ingress:
236 enabled: true
237 className: nginx
238 host: hooks.gitdata.example.com
239 annotations: {}
240 tls:
241 enabled: false
242 secretName: ""
243
244runner:
245 # 默认关闭:多数部署在自有机器上运行 runner 二进制
246 enabled: false
247 replicaCount: 1
248 labels: ["self-hosted", "docker"]
249 capacity: 1
250 dockerEnabled: false
251 serverUrl: ""
252 resources:
253 requests: { cpu: 500m, memory: 512Mi }
254 limits: { memory: 4Gi }
255 extraEnv: []
Modified config/default.toml View config/default.toml
@@ -297,3 +297,53 @@
297297session_table_name = "admin_session"
298298cors_allow_origins = []
299299cors_allow_credentials = false
300
301[ci]
302enabled = false
303secret_encryption_key = ""
304event_queue_topic = "gitdata:events"
305event_dlq_topic = "gitdata:events:dlq"
306lease_secs = 60
307heartbeat_interval_secs = 15
308long_poll_timeout_secs = 30
309max_attempts = 2
310job_timeout_default_secs = 3600
311job_timeout_max_secs = 21600
312log_chunk_max_bytes = 262144
313log_retention_days = 30
314artifact_retention_days = 30
315run_retention_days = 180
316artifact_max_bytes = 2147483648
317outbound_max_attempts = 5
318outbound_backoff_base_secs = 60
319outbound_timeout_secs = 10
320scheduler_interval_secs = 2
321cancel_grace_secs = 120
322
323[webhook]
324enabled = true
325bind_addr = "0.0.0.0"
326port = 3005
327public_base_url = ""
328
329[runner]
330server_url = "http://127.0.0.1:3005"
331registration_token = ""
332name = ""
333labels = ["self-hosted"]
334capacity = 1
335workspace_root = "/var/lib/gitdata-runner"
336credential_path = "/var/lib/gitdata-runner/credential"
337docker_enabled = false
338default_shell = "sh"
339pat = ""
340git_binary = "git"
341docker_binary = "docker"
342clone_timeout_secs = 1800
343default_job_timeout_secs = 3600
344poll_timeout_secs = 30
345heartbeat_interval_secs = 15
346log_flush_interval_ms = 1000
347log_flush_bytes = 65536
348keep_workspace = false
349once = false
Modified lib/admin/src/handlers/admins.rs View lib/admin/src/handlers/admins.rs
@@ -1,15 +1,13 @@
11use axum::Json;
22use axum::extract::{Extension, Path, Query, State};
33use axum::http::HeaderMap;
4use export::middleware::RequestId;
45use export::pagination::{Page, PageQuery};
56use export::rest::{AppResponseData, NoData};
6use export::middleware::RequestId;
77use uuid::Uuid;
88
99use crate::errors::AdminApiError;
10use crate::services::auth::{
11 AdminDto, CreateAdminCmd, ResetAdminPasswordCmd, UpdateAdminCmd,
12};
10use crate::services::auth::{AdminDto, CreateAdminCmd, ResetAdminPasswordCmd, UpdateAdminCmd};
1311use crate::services::service::AdminService;
1412use crate::share::roles::CurrentAdmin;
1513
@@ -151,9 +149,7 @@
151149 Json(cmd): Json<ResetAdminPasswordCmd>,
152150) -> Result<AppResponseData<NoData>, AdminApiError> {
153151 let ctx = audit_ctx(&headers, request_id);
154 service
155 .reset_admin_password(&actor, id, cmd, &ctx)
156 .await?;
152 service.reset_admin_password(&actor, id, cmd, &ctx).await?;
157153 Ok(AppResponseData::empty())
158154}
159155
@@ -179,4 +175,4 @@
179175 let ctx = audit_ctx(&headers, request_id);
180176 service.revoke_admin_sessions(&actor, id, &ctx).await?;
181177 Ok(AppResponseData::empty())
182}
No newline at end of file
178}
Modified lib/admin/src/handlers/auth.rs View lib/admin/src/handlers/auth.rs
@@ -1,8 +1,8 @@
11use axum::Json;
22use axum::extract::{Extension, State};
33use axum::http::HeaderMap;
4use export::middleware::RequestId;
45use export::rest::{AppResponseData, NoData};
5use export::middleware::RequestId;
66use session::Session;
77
88use crate::errors::AdminApiError;
@@ -100,4 +100,4 @@
100100 .change_admin_password(&session, &actor, cmd, &ctx)
101101 .await?;
102102 Ok(AppResponseData::empty())
103}
No newline at end of file
103}
Modified lib/admin/src/handlers/chat.rs View lib/admin/src/handlers/chat.rs
@@ -2,8 +2,8 @@
22use axum::extract::{Extension, Path, Query, State};
33use axum::http::HeaderMap;
44use axum::response::Response;
5use export::middleware::RequestId;
56use export::pagination::Page;
6use export::middleware::RequestId;
77use export::rest::{AppResponseData, NoData};
88use uuid::Uuid;
99
@@ -11,9 +11,9 @@
1111use crate::services::chat::{
1212 AdminChatAttachmentDto, AdminChatMessageDetail, AdminChatMessageDto, AdminChatRoomDetail,
1313 AdminChatRoomMemberDto, AdminChatRoomSummary, AdminChatStatsDto, BulkDeleteChatMessagesCmd,
14 BulkDeleteChatMessagesResp, DeleteChatAttachmentResp, DeleteChatRoomResp, ListChatAttachmentsQuery,
15 ListChatMembersQuery, ListChatMessagesQuery, ListChatRoomsQuery, PurgeChatMessagesResp,
16 RemoveChatMemberResp, UpdateChatRoomCmd,
14 BulkDeleteChatMessagesResp, DeleteChatAttachmentResp, DeleteChatRoomResp,
15 ListChatAttachmentsQuery, ListChatMembersQuery, ListChatMessagesQuery, ListChatRoomsQuery,
16 PurgeChatMessagesResp, RemoveChatMemberResp, UpdateChatRoomCmd,
1717};
1818use crate::services::service::AdminService;
1919use crate::share::roles::CurrentAdmin;
@@ -166,7 +166,9 @@
166166) -> Result<AppResponseData<RemoveChatMemberResp>, AdminApiError> {
167167 let ctx = audit_ctx(&headers, request_id);
168168 Ok(AppResponseData::ok(
169 service.remove_chat_room_member(&actor, id, uid, &ctx).await?,
169 service
170 .remove_chat_room_member(&actor, id, uid, &ctx)
171 .await?,
170172 ))
171173}
172174
@@ -209,7 +211,9 @@
209211 State(service): State<AdminService>,
210212 Query(query): Query<ListChatMessagesQuery>,
211213) -> Result<AppResponseData<Page<AdminChatMessageDto>>, AdminApiError> {
212 Ok(AppResponseData::ok(service.list_chat_messages(query).await?))
214 Ok(AppResponseData::ok(
215 service.list_chat_messages(query).await?,
216 ))
213217}
214218
215219#[utoipa::path(
@@ -241,8 +245,16 @@
241245 row.pinned.to_string(),
242246 row.attachment_count.to_string(),
243247 row.reaction_count.to_string(),
244 csv_field(&row.edited_at.map(|value| value.to_string()).unwrap_or_default()),
245 csv_field(&row.deleted_at.map(|value| value.to_string()).unwrap_or_default()),
248 csv_field(
249 &row.edited_at
250 .map(|value| value.to_string())
251 .unwrap_or_default(),
252 ),
253 csv_field(
254 &row.deleted_at
255 .map(|value| value.to_string())
256 .unwrap_or_default(),
257 ),
246258 csv_field(&row.created_at.to_string()),
247259 ];
248260 body.push_str(&line.join(","));
Modified lib/admin/src/handlers/logs.rs View lib/admin/src/handlers/logs.rs
@@ -78,4 +78,4 @@
7878 body.push('\n');
7979 }
8080 Ok(csv_response("gitdata-admin-audit.csv", body))
81}
No newline at end of file
81}
Modified lib/admin/src/handlers/mod.rs View lib/admin/src/handlers/mod.rs
@@ -3,6 +3,7 @@
33pub mod chat;
44pub mod logs;
55pub mod queues;
6pub mod runners;
67pub mod spaces;
78pub mod storage;
89pub mod system;
@@ -20,6 +21,7 @@
2021pub(crate) fn audit_ctx(headers: &HeaderMap, request_id: Option<Extension<RequestId>>) -> AuditCtx {
2122 AuditCtx::from_headers(headers).with_request_id(request_id.map(|Extension(value)| value.0))
2223}
24
2325pub(crate) fn csv_response(filename: &str, body: String) -> Response {
2426 let mut response = body.into_response();
2527 response.headers_mut().insert(
@@ -40,4 +42,4 @@
4042 } else {
4143 value.to_string()
4244 }
43}
No newline at end of file
45}
Modified lib/admin/src/handlers/queues.rs View lib/admin/src/handlers/queues.rs
@@ -1,7 +1,7 @@
11use axum::extract::{Extension, Path, Query, State};
22use axum::http::HeaderMap;
3use export::middleware::RequestId;
34use export::rest::{AppResponseData, NoData};
4use export::middleware::RequestId;
55use serde::Deserialize;
66use utoipa::IntoParams;
77
@@ -101,4 +101,4 @@
101101 .queue_failed_jobs(&name, query.limit.unwrap_or(20), query.offset.unwrap_or(0))
102102 .await?;
103103 Ok(AppResponseData::ok(result))
104}
No newline at end of file
104}
Added lib/admin/src/handlers/runners.rs View lib/admin/src/handlers/runners.rs
@@ -0,0 +1,281 @@
1use axum::extract::{Extension, Path, Query, State};
2use axum::http::HeaderMap;
3use export::middleware::RequestId;
4use export::rest::AppResponseData;
5use serde::Deserialize;
6use utoipa::IntoParams;
7use uuid::Uuid;
8
9use crate::errors::AdminApiError;
10use crate::services::runners::{
11 CreateRunnerTokenCmd, CreatedRunnerTokenDto, EnvironmentAdminDto, RunnerDto, RunnerPageDto,
12 RunnerTokenDto, UpdateRunnerCmd, UpsertEnvironmentAdminCmd, UpsertEnvironmentSecretCmd,
13};
14use crate::services::service::AdminService;
15use crate::share::roles::CurrentAdmin;
16
17use super::audit_ctx;
18
19#[derive(Debug, Clone, Deserialize, IntoParams)]
20pub struct PageQuery {
21 pub limit: Option<u64>,
22 pub offset: Option<u64>,
23}
24
25#[utoipa::path(
26 get,
27 path = "/admin/runners",
28 tag = "admin-runners",
29 description = "列出已注册的 runner 及其活跃作业数。",
30 params(PageQuery),
31 responses((status = 200, description = "runner list", body = AppResponseData<RunnerPageDto>))
32)]
33pub async fn list_runners(
34 State(service): State<AdminService>,
35 Query(query): Query<PageQuery>,
36) -> Result<AppResponseData<RunnerPageDto>, AdminApiError> {
37 let page = service
38 .list_runners(query.limit.unwrap_or(50), query.offset.unwrap_or(0))
39 .await?;
40 Ok(AppResponseData::ok(page))
41}
42
43#[utoipa::path(
44 patch,
45 path = "/admin/runners/{id}",
46 tag = "admin-runners",
47 description = "更新 runner 的标签、容量或启用状态。",
48 params(("id" = Uuid, Path)),
49 request_body = UpdateRunnerCmd,
50 responses((status = 200, description = "runner updated", body = AppResponseData<RunnerDto>))
51)]
52pub async fn update_runner(
53 State(service): State<AdminService>,
54 CurrentAdmin(actor): CurrentAdmin,
55 Path(id): Path<Uuid>,
56 headers: HeaderMap,
57 request_id: Option<Extension<RequestId>>,
58 axum::Json(cmd): axum::Json<UpdateRunnerCmd>,
59) -> Result<AppResponseData<RunnerDto>, AdminApiError> {
60 let ctx = audit_ctx(&headers, request_id);
61 service
62 .audit_action(&actor, "runner.update", serde_json::json!({}), &ctx)
63 .await;
64 let runner = service.update_runner(id, &cmd).await?;
65 Ok(AppResponseData::ok(runner))
66}
67
68#[utoipa::path(
69 delete,
70 path = "/admin/runners/{id}",
71 tag = "admin-runners",
72 description = "删除 runner 及其凭据。",
73 params(("id" = Uuid, Path)),
74 responses((status = 200, description = "runner deleted", body = AppResponseData<Vec<RunnerDto>>))
75)]
76pub async fn delete_runner(
77 State(service): State<AdminService>,
78 CurrentAdmin(actor): CurrentAdmin,
79 Path(id): Path<Uuid>,
80 headers: HeaderMap,
81 request_id: Option<Extension<RequestId>>,
82) -> Result<AppResponseData<Vec<RunnerDto>>, AdminApiError> {
83 let ctx = audit_ctx(&headers, request_id);
84 service
85 .audit_action(&actor, "runner.delete", serde_json::json!({}), &ctx)
86 .await;
87 service.delete_runner(id).await?;
88 let page = service.list_runners(50, 0).await?;
89 Ok(AppResponseData::ok(page.items))
90}
91
92#[utoipa::path(
93 get,
94 path = "/admin/runner-tokens",
95 tag = "admin-runners",
96 description = "列出注册令牌(不含明文)。",
97 responses((status = 200, description = "token list", body = AppResponseData<Vec<RunnerTokenDto>>))
98)]
99pub async fn list_runner_tokens(
100 State(service): State<AdminService>,
101) -> Result<AppResponseData<Vec<RunnerTokenDto>>, AdminApiError> {
102 let tokens = service.list_runner_tokens().await?;
103 Ok(AppResponseData::ok(tokens))
104}
105
106#[utoipa::path(
107 post,
108 path = "/admin/runner-tokens",
109 tag = "admin-runners",
110 description = "创建一次性 runner 注册令牌;明文只在本次响应中返回。",
111 request_body = CreateRunnerTokenCmd,
112 responses((status = 200, description = "token created", body = AppResponseData<CreatedRunnerTokenDto>))
113)]
114pub async fn create_runner_token(
115 State(service): State<AdminService>,
116 CurrentAdmin(actor): CurrentAdmin,
117 headers: HeaderMap,
118 request_id: Option<Extension<RequestId>>,
119 axum::Json(cmd): axum::Json<CreateRunnerTokenCmd>,
120) -> Result<AppResponseData<CreatedRunnerTokenDto>, AdminApiError> {
121 let ctx = audit_ctx(&headers, request_id);
122 service
123 .audit_action(&actor, "runner.token.create", serde_json::json!({}), &ctx)
124 .await;
125 let created = service.create_runner_token(&cmd).await?;
126 Ok(AppResponseData::ok(created))
127}
128
129#[utoipa::path(
130 post,
131 path = "/admin/runner-tokens/{id}/revoke",
132 tag = "admin-runners",
133 description = "吊销注册令牌或 runner 凭据。",
134 params(("id" = Uuid, Path)),
135 responses((status = 200, description = "token revoked", body = AppResponseData<Vec<RunnerTokenDto>>))
136)]
137pub async fn revoke_runner_token(
138 State(service): State<AdminService>,
139 CurrentAdmin(actor): CurrentAdmin,
140 Path(id): Path<Uuid>,
141 headers: HeaderMap,
142 request_id: Option<Extension<RequestId>>,
143) -> Result<AppResponseData<Vec<RunnerTokenDto>>, AdminApiError> {
144 let ctx = audit_ctx(&headers, request_id);
145 service
146 .audit_action(&actor, "runner.token.revoke", serde_json::json!({}), &ctx)
147 .await;
148 service.revoke_runner_token(id).await?;
149 let tokens = service.list_runner_tokens().await?;
150 Ok(AppResponseData::ok(tokens))
151}
152
153#[utoipa::path(
154 get,
155 path = "/admin/spaces/{id}/environments",
156 tag = "admin-runners",
157 description = "列出某个空间的部署环境与已配置的 secret 名称。",
158 params(("id" = Uuid, Path)),
159 responses((status = 200, description = "environment list", body = AppResponseData<Vec<EnvironmentAdminDto>>))
160)]
161pub async fn list_environments(
162 State(service): State<AdminService>,
163 Path(id): Path<Uuid>,
164) -> Result<AppResponseData<Vec<EnvironmentAdminDto>>, AdminApiError> {
165 let environments = service.list_environments(id).await?;
166 Ok(AppResponseData::ok(environments))
167}
168
169#[utoipa::path(
170 put,
171 path = "/admin/spaces/{id}/environments",
172 tag = "admin-runners",
173 description = "创建或更新空间下的部署环境。",
174 params(("id" = Uuid, Path)),
175 request_body = UpsertEnvironmentAdminCmd,
176 responses((status = 200, description = "environment upserted", body = AppResponseData<EnvironmentAdminDto>))
177)]
178pub async fn upsert_environment(
179 State(service): State<AdminService>,
180 CurrentAdmin(actor): CurrentAdmin,
181 Path(id): Path<Uuid>,
182 headers: HeaderMap,
183 request_id: Option<Extension<RequestId>>,
184 axum::Json(cmd): axum::Json<UpsertEnvironmentAdminCmd>,
185) -> Result<AppResponseData<EnvironmentAdminDto>, AdminApiError> {
186 let ctx = audit_ctx(&headers, request_id);
187 service
188 .audit_action(&actor, "environment.upsert", serde_json::json!({}), &ctx)
189 .await;
190 let environment = service.upsert_environment(id, &cmd).await?;
191 Ok(AppResponseData::ok(environment))
192}
193
194#[utoipa::path(
195 delete,
196 path = "/admin/environments/{id}",
197 tag = "admin-runners",
198 description = "删除部署环境及其 secrets 与部署历史。",
199 params(("id" = Uuid, Path)),
200 responses((status = 200, description = "environment deleted", body = AppResponseData<Vec<EnvironmentAdminDto>>))
201)]
202pub async fn delete_environment(
203 State(service): State<AdminService>,
204 CurrentAdmin(actor): CurrentAdmin,
205 Path(id): Path<Uuid>,
206 headers: HeaderMap,
207 request_id: Option<Extension<RequestId>>,
208) -> Result<AppResponseData<Vec<EnvironmentAdminDto>>, AdminApiError> {
209 let ctx = audit_ctx(&headers, request_id);
210 service
211 .audit_action(&actor, "environment.delete", serde_json::json!({}), &ctx)
212 .await;
213 let space_id = service.environment_space(id).await?;
214 service.delete_environment(id).await?;
215 let environments = service.list_environments(space_id).await?;
216 Ok(AppResponseData::ok(environments))
217}
218
219#[utoipa::path(
220 put,
221 path = "/admin/environments/{id}/secrets",
222 tag = "admin-runners",
223 description = "创建或更新环境级 secret。",
224 params(("id" = Uuid, Path)),
225 request_body = UpsertEnvironmentSecretCmd,
226 responses((status = 200, description = "secret upserted", body = AppResponseData<Vec<EnvironmentAdminDto>>))
227)]
228pub async fn upsert_environment_secret(
229 State(service): State<AdminService>,
230 CurrentAdmin(actor): CurrentAdmin,
231 Path(id): Path<Uuid>,
232 headers: HeaderMap,
233 request_id: Option<Extension<RequestId>>,
234 axum::Json(cmd): axum::Json<UpsertEnvironmentSecretCmd>,
235) -> Result<AppResponseData<Vec<EnvironmentAdminDto>>, AdminApiError> {
236 let ctx = audit_ctx(&headers, request_id);
237 service
238 .audit_action(
239 &actor,
240 "environment.secret.upsert",
241 serde_json::json!({}),
242 &ctx,
243 )
244 .await;
245 let space_id = service.environment_space(id).await?;
246 service
247 .upsert_environment_secret(id, &cmd, Some(actor.id))
248 .await?;
249 let environments = service.list_environments(space_id).await?;
250 Ok(AppResponseData::ok(environments))
251}
252
253#[utoipa::path(
254 delete,
255 path = "/admin/environments/{id}/secrets/{name}",
256 tag = "admin-runners",
257 description = "删除环境级 secret。",
258 params(("id" = Uuid, Path), ("name" = String, Path)),
259 responses((status = 200, description = "secret deleted", body = AppResponseData<Vec<EnvironmentAdminDto>>))
260)]
261pub async fn delete_environment_secret(
262 State(service): State<AdminService>,
263 CurrentAdmin(actor): CurrentAdmin,
264 Path((id, name)): Path<(Uuid, String)>,
265 headers: HeaderMap,
266 request_id: Option<Extension<RequestId>>,
267) -> Result<AppResponseData<Vec<EnvironmentAdminDto>>, AdminApiError> {
268 let ctx = audit_ctx(&headers, request_id);
269 service
270 .audit_action(
271 &actor,
272 "environment.secret.delete",
273 serde_json::json!({}),
274 &ctx,
275 )
276 .await;
277 let space_id = service.environment_space(id).await?;
278 service.delete_environment_secret(id, &name).await?;
279 let environments = service.list_environments(space_id).await?;
280 Ok(AppResponseData::ok(environments))
281}
Modified lib/admin/src/handlers/spaces.rs View lib/admin/src/handlers/spaces.rs
@@ -1,16 +1,17 @@
11use axum::Json;
22use axum::extract::{Extension, Path, Query, State};
33use axum::http::HeaderMap;
4use export::middleware::RequestId;
45use export::pagination::Page;
56use export::rest::{AppResponseData, NoData};
6use export::middleware::RequestId;
77use uuid::Uuid;
88
99use crate::errors::AdminApiError;
1010use crate::services::service::AdminService;
1111use crate::services::spaces::{
12 AddSpaceMemberCmd, AddSpaceMemberResp, AdminSpaceDetail, AdminSpaceMemberDto, AdminSpaceSummary,
13 ListMembersQuery, ListSpacesQuery, RemoveMemberResp, TransferSpaceCmd, UpdateSpaceCmd,
12 AddSpaceMemberCmd, AddSpaceMemberResp, AdminSpaceDetail, AdminSpaceMemberDto,
13 AdminSpaceSummary, ListMembersQuery, ListSpacesQuery, RemoveMemberResp, TransferSpaceCmd,
14 UpdateSpaceCmd,
1415};
1516use crate::share::roles::CurrentAdmin;
1617
@@ -200,4 +201,4 @@
200201 let ctx = audit_ctx(&headers, request_id);
201202 let result = service.add_space_member(&actor, id, cmd, &ctx).await?;
202203 Ok(AppResponseData::ok(result))
203}
No newline at end of file
204}
Modified lib/admin/src/handlers/storage.rs View lib/admin/src/handlers/storage.rs
@@ -1,7 +1,7 @@
11use axum::extract::{Extension, Query, State};
22use axum::http::HeaderMap;
3use export::middleware::RequestId;
34use export::rest::{AppResponseData, NoData};
4use export::middleware::RequestId;
55use serde::Deserialize;
66use utoipa::IntoParams;
77
@@ -35,7 +35,9 @@
3535 State(service): State<AdminService>,
3636 Query(query): Query<StorageOverviewQuery>,
3737) -> Result<AppResponseData<StorageOverviewDto>, AdminApiError> {
38 let result = service.storage_overview(query.refresh.unwrap_or(false)).await?;
38 let result = service
39 .storage_overview(query.refresh.unwrap_or(false))
40 .await?;
3941 Ok(AppResponseData::ok(result))
4042}
4143
@@ -96,6 +98,8 @@
9698 request_id: Option<Extension<RequestId>>,
9799) -> Result<AppResponseData<DeleteObjectResp>, AdminApiError> {
98100 let ctx = audit_ctx(&headers, request_id);
99 let result = service.delete_storage_object(&actor, query.key, &ctx).await?;
101 let result = service
102 .delete_storage_object(&actor, query.key, &ctx)
103 .await?;
100104 Ok(AppResponseData::ok(result))
101}
No newline at end of file
105}
Modified lib/admin/src/handlers/system.rs View lib/admin/src/handlers/system.rs
@@ -52,4 +52,4 @@
5252) -> Result<AppResponseData<DashboardDto>, AdminApiError> {
5353 let result = service.dashboard().await?;
5454 Ok(AppResponseData::ok(result))
55}
No newline at end of file
55}
Modified lib/admin/src/handlers/tasks.rs View lib/admin/src/handlers/tasks.rs
@@ -1,7 +1,7 @@
11use axum::extract::{Extension, Path, Query, State};
22use axum::http::HeaderMap;
3use export::middleware::RequestId;
34use export::rest::{AppResponseData, NoData};
4use export::middleware::RequestId;
55use uuid::Uuid;
66
77use crate::errors::AdminApiError;
@@ -171,4 +171,4 @@
171171 let ctx = audit_ctx(&headers, request_id);
172172 let result = service.bulk_tasks(&actor, &source, cmd, &ctx).await?;
173173 Ok(AppResponseData::ok(result))
174}
No newline at end of file
174}
Modified lib/admin/src/handlers/users.rs View lib/admin/src/handlers/users.rs
@@ -2,9 +2,9 @@
22use axum::extract::{Extension, Path, Query, State};
33use axum::http::HeaderMap;
44use axum::response::Response;
5use export::middleware::RequestId;
56use export::pagination::Page;
67use export::rest::{AppResponseData, NoData};
7use export::middleware::RequestId;
88use uuid::Uuid;
99
1010use crate::errors::AdminApiError;
@@ -12,8 +12,8 @@
1212use crate::services::users::{
1313 AdminSuspensionDto, AdminUserDetail, AdminUserSecurityDto, AdminUserSessionPage,
1414 AdminUserSummary, BulkUsersCmd, BulkUsersResp, CreateUserCmd, CreateUserResp, DeleteUserResp,
15 ListUserSessionsQuery, ListUsersQuery, ResetUserPasswordCmd, RevokeSessionsResp, SuspendUserCmd,
16 UpdateUserCmd,
15 ListUserSessionsQuery, ListUsersQuery, ResetUserPasswordCmd, RevokeSessionsResp,
16 SuspendUserCmd, UpdateUserCmd,
1717};
1818use crate::share::roles::CurrentAdmin;
1919
@@ -158,9 +158,7 @@
158158 Json(cmd): Json<ResetUserPasswordCmd>,
159159) -> Result<AppResponseData<NoData>, AdminApiError> {
160160 let ctx = audit_ctx(&headers, request_id);
161 service
162 .reset_user_password(&actor, uid, cmd, &ctx)
163 .await?;
161 service.reset_user_password(&actor, uid, cmd, &ctx).await?;
164162 Ok(AppResponseData::empty())
165163}
166164
@@ -183,7 +181,9 @@
183181 request_id: Option<Extension<RequestId>>,
184182) -> Result<AppResponseData<RevokeSessionsResp>, AdminApiError> {
185183 let ctx = audit_ctx(&headers, request_id);
186 let result = service.admin_revoke_user_sessions(&actor, uid, &ctx).await?;
184 let result = service
185 .admin_revoke_user_sessions(&actor, uid, &ctx)
186 .await?;
187187 Ok(AppResponseData::ok(result))
188188}
189189
@@ -289,7 +289,11 @@
289289 row.bot.to_string(),
290290 row.system.to_string(),
291291 csv_field(&row.created_at.to_string()),
292 csv_field(&row.last_login_at.map(|value| value.to_string()).unwrap_or_default()),
292 csv_field(
293 &row.last_login_at
294 .map(|value| value.to_string())
295 .unwrap_or_default(),
296 ),
293297 ];
294298 body.push_str(&line.join(","));
295299 body.push('\n');
@@ -386,8 +390,6 @@
386390 request_id: Option<Extension<RequestId>>,
387391) -> Result<AppResponseData<NoData>, AdminApiError> {
388392 let ctx = audit_ctx(&headers, request_id);
389 service
390 .clear_user_two_factor(&actor, uid, &ctx)
391 .await?;
393 service.clear_user_two_factor(&actor, uid, &ctx).await?;
392394 Ok(AppResponseData::empty())
393}
No newline at end of file
395}
Modified lib/admin/src/services/audit.rs View lib/admin/src/services/audit.rs
@@ -85,4 +85,4 @@
8585 .await?;
8686 Ok(())
8787 }
88}
No newline at end of file
88}
Modified lib/admin/src/services/auth.rs View lib/admin/src/services/auth.rs
@@ -1,7 +1,9 @@
11use export::pagination::{Page, PageQuery};
22use models::admin::admin_user::{self, AdminRole};
33use sea_orm::PaginatorTrait;
4use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
4use sea_orm::{
5 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set,
6};
57use serde::{Deserialize, Serialize};
68use serde_json::json;
79use session::Session;
@@ -520,7 +522,10 @@
520522 ctx: &AuditCtx,
521523 ) -> Result<(), AdminError> {
522524 actor.require(AdminRole::SuperAdmin)?;
523 auth::share::crypto::validate_password(&cmd.new_password, &self.config().auth.password_policy)?;
525 auth::share::crypto::validate_password(
526 &cmd.new_password,
527 &self.config().auth.password_policy,
528 )?;
524529 let row = admin_user::Entity::find_by_id(id)
525530 .one(self.db().read())
526531 .await?
@@ -624,4 +629,4 @@
624629 .await?;
625630 Ok(())
626631 }
627}
No newline at end of file
632}
Modified lib/admin/src/services/chat.rs View lib/admin/src/services/chat.rs
@@ -21,12 +21,12 @@
2121use models::room::rooms::{self, RoomKind};
2222use models::space::spaces;
2323use models::users::user;
24use sea_orm::ExprTrait;
2425use sea_orm::sea_query::Expr;
2526use sea_orm::{
2627 ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, EntityTrait, PaginatorTrait,
2728 QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait,
2829};
29use sea_orm::ExprTrait;
3030use serde::{Deserialize, Serialize};
3131use serde_json::json;
3232use time::OffsetDateTime;
@@ -358,7 +358,6 @@
358358 AttachmentStatus::Uploaded => "uploaded",
359359 }
360360}
361
362361
363362impl AdminService {
364363 async fn username_map(
@@ -432,13 +431,14 @@
432431 .count(self.db().read())
433432 .await? as i64;
434433 let messages_24h = messages::Entity::find()
435 .filter(messages::Column::CreatedAt.gte(OffsetDateTime::now_utc() - time::Duration::hours(24)))
434 .filter(
435 messages::Column::CreatedAt
436 .gte(OffsetDateTime::now_utc() - time::Duration::hours(24)),
437 )
436438 .count(self.db().read())
437439 .await? as i64;
438440 let threads = threads::Entity::find().count(self.db().read()).await? as i64;
439 let attachments = attachments::Entity::find()
440 .count(self.db().read())
441 .await? as i64;
441 let attachments = attachments::Entity::find().count(self.db().read()).await? as i64;
442442 let pending_attachments = attachments::Entity::find()
443443 .filter(attachments::Column::Status.eq(AttachmentStatus::Pending))
444444 .count(self.db().read())
@@ -473,7 +473,10 @@
473473 })
474474 }
475475
476 async fn room_message_counts(&self, room_ids: &[Uuid]) -> Result<HashMap<Uuid, i64>, AdminError> {
476 async fn room_message_counts(
477 &self,
478 room_ids: &[Uuid],
479 ) -> Result<HashMap<Uuid, i64>, AdminError> {
477480 if room_ids.is_empty() {
478481 return Ok(HashMap::new());
479482 }
@@ -490,7 +493,10 @@
490493 Ok(rows.into_iter().collect())
491494 }
492495
493 async fn room_member_counts(&self, room_ids: &[Uuid]) -> Result<HashMap<Uuid, i64>, AdminError> {
496 async fn room_member_counts(
497 &self,
498 room_ids: &[Uuid],
499 ) -> Result<HashMap<Uuid, i64>, AdminError> {
494500 if room_ids.is_empty() {
495501 return Ok(HashMap::new());
496502 }
@@ -581,10 +587,7 @@
581587 &self,
582588 query: ListChatRoomsQuery,
583589 ) -> Result<Page<AdminChatRoomSummary>, AdminError> {
584 let limit = query
585 .limit
586 .unwrap_or(50)
587 .clamp(1, MAX_CHAT_PAGE);
590 let limit = query.limit.unwrap_or(50).clamp(1, MAX_CHAT_PAGE);
588591 let offset = query.offset.unwrap_or(0);
589592 let select = self.room_select(&query).await?;
590593 let total = select.clone().count(self.db().read()).await? as i64;
@@ -801,8 +804,8 @@
801804 .ok_or(AdminError::NotFound)?;
802805 let limit = query.limit.unwrap_or(50).clamp(1, MAX_CHAT_PAGE);
803806 let offset = query.offset.unwrap_or(0);
804 let mut select = room_members::Entity::find()
805 .filter(room_members::Column::RoomId.eq(room_id));
807 let mut select =
808 room_members::Entity::find().filter(room_members::Column::RoomId.eq(room_id));
806809 select = match query.status.as_deref() {
807810 Some("active") => select.filter(room_members::Column::LeftAt.is_null()),
808811 Some("left") => select.filter(room_members::Column::LeftAt.is_not_null()),
@@ -1042,7 +1045,13 @@
10421045 Ok(rows
10431046 .iter()
10441047 .map(|row| {
1045 Self::message_dto(row, &room_names, &authors, &attachment_counts, &reaction_counts)
1048 Self::message_dto(
1049 row,
1050 &room_names,
1051 &authors,
1052 &attachment_counts,
1053 &reaction_counts,
1054 )
10461055 })
10471056 .collect())
10481057 }
@@ -1177,11 +1186,15 @@
11771186 .exec(txn)
11781187 .await?;
11791188 models::users::user_saved_messages::Entity::delete_many()
1180 .filter(models::users::user_saved_messages::Column::MessageId.is_in(message_ids.to_vec()))
1189 .filter(
1190 models::users::user_saved_messages::Column::MessageId.is_in(message_ids.to_vec()),
1191 )
11811192 .exec(txn)
11821193 .await?;
11831194 models::users::user_recent_mentions::Entity::delete_many()
1184 .filter(models::users::user_recent_mentions::Column::MessageId.is_in(message_ids.to_vec()))
1195 .filter(
1196 models::users::user_recent_mentions::Column::MessageId.is_in(message_ids.to_vec()),
1197 )
11851198 .exec(txn)
11861199 .await?;
11871200 models::chat::message_reminders::Entity::delete_many()
@@ -1341,7 +1354,10 @@
13411354 .await?;
13421355 if !thread_ids.is_empty() {
13431356 models::chat::thread_members::Entity::delete_many()
1344 .filter(models::chat::thread_members::Column::ThreadId.is_in(thread_ids.clone()))
1357 .filter(
1358 models::chat::thread_members::Column::ThreadId
1359 .is_in(thread_ids.clone()),
1360 )
13451361 .exec(txn)
13461362 .await?;
13471363 threads::Entity::delete_many()
@@ -1372,8 +1388,14 @@
13721388 }
13731389 rooms::Entity::update_many()
13741390 .filter(rooms::Column::Id.eq(room_id))
1375 .col_expr(rooms::Column::LastMessageId, Expr::value(Option::<Uuid>::None))
1376 .col_expr(rooms::Column::UpdatedAt, Expr::value(OffsetDateTime::now_utc()))
1391 .col_expr(
1392 rooms::Column::LastMessageId,
1393 Expr::value(Option::<Uuid>::None),
1394 )
1395 .col_expr(
1396 rooms::Column::UpdatedAt,
1397 Expr::value(OffsetDateTime::now_utc()),
1398 )
13771399 .exec(self.db().write())
13781400 .await?;
13791401 self.audit(
@@ -1573,8 +1595,12 @@
15731595 select = select.filter(attachments::Column::UserId.eq(user_id));
15741596 }
15751597 select = match query.status.as_deref() {
1576 Some("pending") => select.filter(attachments::Column::Status.eq(AttachmentStatus::Pending)),
1577 Some("uploaded") => select.filter(attachments::Column::Status.eq(AttachmentStatus::Uploaded)),
1598 Some("pending") => {
1599 select.filter(attachments::Column::Status.eq(AttachmentStatus::Pending))
1600 }
1601 Some("uploaded") => {
1602 select.filter(attachments::Column::Status.eq(AttachmentStatus::Uploaded))
1603 }
15781604 _ => select,
15791605 };
15801606 if let Some(raw) = query
Modified lib/admin/src/services/logs.rs View lib/admin/src/services/logs.rs
@@ -1,7 +1,7 @@
11use models::admin::admin_audit_log;
22use models::chat::webhook_deliveries;
3use models::ci::workflow_run::{self, WorkflowRunState};
34use models::import::job;
4use models::repo::pipeline_run::{self, PipelineRunState};
55use models::search::index_job;
66use models::system::system_tasks;
77use sea_orm::{
@@ -208,14 +208,14 @@
208208 }
209209 }
210210 "pipeline" => {
211 let mut select = pipeline_run::Entity::find()
212 .filter(pipeline_run::Column::State.eq(PipelineRunState::Failure))
213 .order_by_desc(pipeline_run::Column::UpdatedAt);
211 let mut select = workflow_run::Entity::find()
212 .filter(workflow_run::Column::State.eq(WorkflowRunState::Failure))
213 .order_by_desc(workflow_run::Column::UpdatedAt);
214214 if let Some(from) = query.from {
215 select = select.filter(pipeline_run::Column::CreatedAt.gte(from));
215 select = select.filter(workflow_run::Column::CreatedAt.gte(from));
216216 }
217217 if let Some(to) = query.to {
218 select = select.filter(pipeline_run::Column::CreatedAt.lte(to));
218 select = select.filter(workflow_run::Column::CreatedAt.lte(to));
219219 }
220220 for row in select.limit(fetch_limit).all(self.db().read()).await? {
221221 logs.push(JobLogDto {
@@ -284,9 +284,8 @@
284284 .map(str::trim)
285285 .filter(|value| !value.is_empty())
286286 {
287 select = select.filter(
288 admin_audit_log::Column::AdminUsername.eq(username.to_ascii_lowercase()),
289 );
287 select = select
288 .filter(admin_audit_log::Column::AdminUsername.eq(username.to_ascii_lowercase()));
290289 }
291290 if let Some(action) = query
292291 .action
@@ -294,8 +293,8 @@
294293 .map(str::trim)
295294 .filter(|value| !value.is_empty())
296295 {
297 select =
298 select.filter(admin_audit_log::Column::Action.contains(action.to_ascii_lowercase()));
296 select = select
297 .filter(admin_audit_log::Column::Action.contains(action.to_ascii_lowercase()));
299298 }
300299 if let Some(target_type) = query
301300 .target_type
@@ -382,9 +381,7 @@
382381 "system_tasks" => Some("system_tasks"),
383382 "git_sync" | "email" | "deletion" => None,
384383 other => {
385 return Err(AdminError::BadRequest(format!(
386 "unknown queue: {other}"
387 )));
384 return Err(AdminError::BadRequest(format!("unknown queue: {other}")));
388385 }
389386 };
390387 let Some(source) = source else {
@@ -404,4 +401,4 @@
404401 })
405402 .await
406403 }
407}
No newline at end of file
404}
Modified lib/admin/src/services/mod.rs View lib/admin/src/services/mod.rs
@@ -3,9 +3,10 @@
33pub mod chat;
44pub mod logs;
55pub mod queues;
6pub mod runners;
67pub mod service;
78pub mod spaces;
89pub mod storage;
910pub mod system;
1011pub mod tasks;
11pub mod users;
No newline at end of file
12pub mod users;
Modified lib/admin/src/services/queues.rs View lib/admin/src/services/queues.rs
@@ -1,7 +1,7 @@
11use models::admin::admin_user::AdminRole;
22use models::chat::webhook_deliveries::{self, WebhookDeliveryStatus};
3use models::ci::workflow_run::{self, WorkflowRunState};
34use models::import::job;
4use models::repo::pipeline_run::{self, PipelineRunState};
55use models::search::index_job;
66use models::system::system_tasks::{self, TaskStatus};
77use models::users::user_deletion_requests::{self, UserDeletionRequestStatus};
@@ -50,10 +50,16 @@
5050 let mut conn = self.redis().connection_clone();
5151 let depth: i64 = match &mut conn {
5252 export::RedisConnection::Standalone(connection) => {
53 redis::cmd("LLEN").arg(&topic).query_async(connection).await?
53 redis::cmd("LLEN")
54 .arg(&topic)
55 .query_async(connection)
56 .await?
5457 }
5558 export::RedisConnection::Cluster(connection) => {
56 redis::cmd("LLEN").arg(&topic).query_async(connection).await?
59 redis::cmd("LLEN")
60 .arg(&topic)
61 .query_async(connection)
62 .await?
5763 }
5864 };
5965 Ok(depth)
@@ -178,20 +184,20 @@
178184 note: None,
179185 });
180186
181 let pipeline_queued = pipeline_run::Entity::find()
182 .filter(pipeline_run::Column::State.eq(PipelineRunState::Queued))
187 let pipeline_queued = workflow_run::Entity::find()
188 .filter(workflow_run::Column::State.eq(WorkflowRunState::Queued))
183189 .count(self.db().read())
184190 .await? as i64;
185 let pipeline_running = pipeline_run::Entity::find()
186 .filter(pipeline_run::Column::State.eq(PipelineRunState::Running))
191 let pipeline_running = workflow_run::Entity::find()
192 .filter(workflow_run::Column::State.eq(WorkflowRunState::Running))
187193 .count(self.db().read())
188194 .await? as i64;
189 let pipeline_failed = pipeline_run::Entity::find()
190 .filter(pipeline_run::Column::State.eq(PipelineRunState::Failure))
195 let pipeline_failed = workflow_run::Entity::find()
196 .filter(workflow_run::Column::State.eq(WorkflowRunState::Failure))
191197 .count(self.db().read())
192198 .await? as i64;
193 let pipeline_succeeded = pipeline_run::Entity::find()
194 .filter(pipeline_run::Column::State.eq(PipelineRunState::Success))
199 let pipeline_succeeded = workflow_run::Entity::find()
200 .filter(workflow_run::Column::State.eq(WorkflowRunState::Success))
195201 .count(self.db().read())
196202 .await? as i64;
197203 snapshots.push(QueueSnapshotDto {
@@ -270,108 +276,123 @@
270276 actor.require(AdminRole::Operator)?;
271277 let now = time::OffsetDateTime::now_utc();
272278 let affected = match queue {
273 QUEUE_SEARCH_INDEX => index_job::Entity::update_many()
274 .col_expr(index_job::Column::State, Expr::value("queued"))
275 .col_expr(index_job::Column::RunAfter, Expr::value(now))
276 .col_expr(
277 index_job::Column::LockedAt,
278 Expr::value(Option::<time::OffsetDateTime>::None),
279 )
280 .col_expr(index_job::Column::UpdatedAt, Expr::value(now))
281 .filter(index_job::Column::LastError.is_not_null())
282 .filter(index_job::Column::State.eq("queued"))
283 .exec(self.db().write())
284 .await?
285 .rows_affected,
286 QUEUE_IMPORT => job::Entity::update_many()
287 .col_expr(job::Column::State, Expr::value("queued"))
288 .col_expr(job::Column::RunAfter, Expr::value(now))
289 .col_expr(
290 job::Column::LockedAt,
291 Expr::value(Option::<time::OffsetDateTime>::None),
292 )
293 .col_expr(
294 job::Column::FinishedAt,
295 Expr::value(Option::<time::OffsetDateTime>::None),
296 )
297 .col_expr(job::Column::UpdatedAt, Expr::value(now))
298 .filter(job::Column::State.is_in(["failed", "canceled"]))
299 .exec(self.db().write())
300 .await?
301 .rows_affected,
302 QUEUE_CHAT_WEBHOOK => webhook_deliveries::Entity::update_many()
303 .col_expr(
304 webhook_deliveries::Column::Status,
305 Expr::value(WebhookDeliveryStatus::Pending),
306 )
307 .col_expr(webhook_deliveries::Column::NextRetryAt, Expr::value(now))
308 .col_expr(webhook_deliveries::Column::UpdatedAt, Expr::value(now))
309 .filter(webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Failed))
310 .exec(self.db().write())
311 .await?
312 .rows_affected,
313 QUEUE_PIPELINE => pipeline_run::Entity::update_many()
314 .col_expr(
315 pipeline_run::Column::State,
316 Expr::value(PipelineRunState::Queued),
317 )
318 .col_expr(
319 pipeline_run::Column::StartedAt,
320 Expr::value(Option::<time::OffsetDateTime>::None),
321 )
322 .col_expr(
323 pipeline_run::Column::FinishedAt,
324 Expr::value(Option::<time::OffsetDateTime>::None),
325 )
326 .col_expr(pipeline_run::Column::UpdatedAt, Expr::value(now))
327 .filter(
328 pipeline_run::Column::State
329 .is_in([PipelineRunState::Failure, PipelineRunState::Cancelled]),
330 )
331 .exec(self.db().write())
332 .await?
333 .rows_affected,
334 QUEUE_DELETION => user_deletion_requests::Entity::update_many()
335 .col_expr(
336 user_deletion_requests::Column::Status,
337 Expr::value(UserDeletionRequestStatus::Scheduled),
338 )
339 .col_expr(
340 user_deletion_requests::Column::ScheduledFor,
341 Expr::value(Some(now)),
342 )
343 .col_expr(
344 user_deletion_requests::Column::CancelledAt,
345 Expr::value(Option::<time::OffsetDateTime>::None),
346 )
347 .col_expr(user_deletion_requests::Column::UpdatedAt, Expr::value(now))
348 .filter(
349 user_deletion_requests::Column::Status
350 .eq(UserDeletionRequestStatus::Cancelled),
351 )
352 .exec(self.db().write())
353 .await?
354 .rows_affected,
355 QUEUE_SYSTEM_TASKS => system_tasks::Entity::update_many()
356 .col_expr(system_tasks::Column::Status, Expr::value(TaskStatus::Pending))
357 .col_expr(system_tasks::Column::NextRunAt, Expr::value(Some(now)))
358 .col_expr(
359 system_tasks::Column::Error,
360 Expr::value(Option::<String>::None),
361 )
362 .col_expr(
363 system_tasks::Column::StartedAt,
364 Expr::value(Option::<time::OffsetDateTime>::None),
365 )
366 .col_expr(
367 system_tasks::Column::FinishedAt,
368 Expr::value(Option::<time::OffsetDateTime>::None),
369 )
370 .col_expr(system_tasks::Column::UpdatedAt, Expr::value(now))
371 .filter(system_tasks::Column::Status.eq(TaskStatus::Failed))
372 .exec(self.db().write())
373 .await?
374 .rows_affected,
279 QUEUE_SEARCH_INDEX => {
280 index_job::Entity::update_many()
281 .col_expr(index_job::Column::State, Expr::value("queued"))
282 .col_expr(index_job::Column::RunAfter, Expr::value(now))
283 .col_expr(
284 index_job::Column::LockedAt,
285 Expr::value(Option::<time::OffsetDateTime>::None),
286 )
287 .col_expr(index_job::Column::UpdatedAt, Expr::value(now))
288 .filter(index_job::Column::LastError.is_not_null())
289 .filter(index_job::Column::State.eq("queued"))
290 .exec(self.db().write())
291 .await?
292 .rows_affected
293 }
294 QUEUE_IMPORT => {
295 job::Entity::update_many()
296 .col_expr(job::Column::State, Expr::value("queued"))
297 .col_expr(job::Column::RunAfter, Expr::value(now))
298 .col_expr(
299 job::Column::LockedAt,
300 Expr::value(Option::<time::OffsetDateTime>::None),
301 )
302 .col_expr(
303 job::Column::FinishedAt,
304 Expr::value(Option::<time::OffsetDateTime>::None),
305 )
306 .col_expr(job::Column::UpdatedAt, Expr::value(now))
307 .filter(job::Column::State.is_in(["failed", "canceled"]))
308 .exec(self.db().write())
309 .await?
310 .rows_affected
311 }
312 QUEUE_CHAT_WEBHOOK => {
313 webhook_deliveries::Entity::update_many()
314 .col_expr(
315 webhook_deliveries::Column::Status,
316 Expr::value(WebhookDeliveryStatus::Pending),
317 )
318 .col_expr(webhook_deliveries::Column::NextRetryAt, Expr::value(now))
319 .col_expr(webhook_deliveries::Column::UpdatedAt, Expr::value(now))
320 .filter(webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Failed))
321 .exec(self.db().write())
322 .await?
323 .rows_affected
324 }
325 QUEUE_PIPELINE => {
326 workflow_run::Entity::update_many()
327 .col_expr(
328 workflow_run::Column::State,
329 Expr::value(WorkflowRunState::Queued),
330 )
331 .col_expr(
332 workflow_run::Column::StartedAt,
333 Expr::value(Option::<time::OffsetDateTime>::None),
334 )
335 .col_expr(
336 workflow_run::Column::FinishedAt,
337 Expr::value(Option::<time::OffsetDateTime>::None),
338 )
339 .col_expr(workflow_run::Column::UpdatedAt, Expr::value(now))
340 .filter(
341 workflow_run::Column::State
342 .is_in([WorkflowRunState::Failure, WorkflowRunState::Cancelled]),
343 )
344 .exec(self.db().write())
345 .await?
346 .rows_affected
347 }
348 QUEUE_DELETION => {
349 user_deletion_requests::Entity::update_many()
350 .col_expr(
351 user_deletion_requests::Column::Status,
352 Expr::value(UserDeletionRequestStatus::Scheduled),
353 )
354 .col_expr(
355 user_deletion_requests::Column::ScheduledFor,
356 Expr::value(Some(now)),
357 )
358 .col_expr(
359 user_deletion_requests::Column::CancelledAt,
360 Expr::value(Option::<time::OffsetDateTime>::None),
361 )
362 .col_expr(user_deletion_requests::Column::UpdatedAt, Expr::value(now))
363 .filter(
364 user_deletion_requests::Column::Status
365 .eq(UserDeletionRequestStatus::Cancelled),
366 )
367 .exec(self.db().write())
368 .await?
369 .rows_affected
370 }
371 QUEUE_SYSTEM_TASKS => {
372 system_tasks::Entity::update_many()
373 .col_expr(
374 system_tasks::Column::Status,
375 Expr::value(TaskStatus::Pending),
376 )
377 .col_expr(system_tasks::Column::NextRunAt, Expr::value(Some(now)))
378 .col_expr(
379 system_tasks::Column::Error,
380 Expr::value(Option::<String>::None),
381 )
382 .col_expr(
383 system_tasks::Column::StartedAt,
384 Expr::value(Option::<time::OffsetDateTime>::None),
385 )
386 .col_expr(
387 system_tasks::Column::FinishedAt,
388 Expr::value(Option::<time::OffsetDateTime>::None),
389 )
390 .col_expr(system_tasks::Column::UpdatedAt, Expr::value(now))
391 .filter(system_tasks::Column::Status.eq(TaskStatus::Failed))
392 .exec(self.db().write())
393 .await?
394 .rows_affected
395 }
375396 _ => {
376397 return Err(AdminError::BadRequest(format!(
377398 "queue {queue} does not support requeue"
@@ -407,59 +428,75 @@
407428 let mut conn = self.redis().connection_clone();
408429 let deleted: i64 = match &mut conn {
409430 export::RedisConnection::Standalone(connection) => {
410 redis::cmd("DEL").arg(&topic).query_async(connection).await?
431 redis::cmd("DEL")
432 .arg(&topic)
433 .query_async(connection)
434 .await?
411435 }
412436 export::RedisConnection::Cluster(connection) => {
413 redis::cmd("DEL").arg(&topic).query_async(connection).await?
437 redis::cmd("DEL")
438 .arg(&topic)
439 .query_async(connection)
440 .await?
414441 }
415442 };
416443 deleted.max(0) as u64
417444 }
418 QUEUE_SEARCH_INDEX => index_job::Entity::delete_many()
419 .filter(index_job::Column::State.is_in(["succeeded", "cancelled"]))
420 .exec(self.db().write())
421 .await?
422 .rows_affected,
423 QUEUE_IMPORT => job::Entity::delete_many()
424 .filter(job::Column::State.is_in(["succeeded", "failed", "canceled"]))
425 .exec(self.db().write())
426 .await?
427 .rows_affected,
428 QUEUE_CHAT_WEBHOOK => webhook_deliveries::Entity::delete_many()
429 .filter(
430 webhook_deliveries::Column::Status
431 .is_in([WebhookDeliveryStatus::Done, WebhookDeliveryStatus::Failed]),
432 )
433 .exec(self.db().write())
434 .await?
435 .rows_affected,
436 QUEUE_PIPELINE => pipeline_run::Entity::delete_many()
437 .filter(
438 pipeline_run::Column::State.is_in([
439 PipelineRunState::Success,
440 PipelineRunState::Failure,
441 PipelineRunState::Cancelled,
442 ]),
443 )
444 .exec(self.db().write())
445 .await?
446 .rows_affected,
447 QUEUE_DELETION => user_deletion_requests::Entity::delete_many()
448 .filter(user_deletion_requests::Column::Status.is_in([
449 UserDeletionRequestStatus::Completed,
450 UserDeletionRequestStatus::Cancelled,
451 ]))
452 .exec(self.db().write())
453 .await?
454 .rows_affected,
455 QUEUE_SYSTEM_TASKS => system_tasks::Entity::delete_many()
456 .filter(
457 system_tasks::Column::Status
458 .is_in([TaskStatus::Success, TaskStatus::Failed]),
459 )
460 .exec(self.db().write())
461 .await?
462 .rows_affected,
445 QUEUE_SEARCH_INDEX => {
446 index_job::Entity::delete_many()
447 .filter(index_job::Column::State.is_in(["succeeded", "cancelled"]))
448 .exec(self.db().write())
449 .await?
450 .rows_affected
451 }
452 QUEUE_IMPORT => {
453 job::Entity::delete_many()
454 .filter(job::Column::State.is_in(["succeeded", "failed", "canceled"]))
455 .exec(self.db().write())
456 .await?
457 .rows_affected
458 }
459 QUEUE_CHAT_WEBHOOK => {
460 webhook_deliveries::Entity::delete_many()
461 .filter(
462 webhook_deliveries::Column::Status
463 .is_in([WebhookDeliveryStatus::Done, WebhookDeliveryStatus::Failed]),
464 )
465 .exec(self.db().write())
466 .await?
467 .rows_affected
468 }
469 QUEUE_PIPELINE => {
470 workflow_run::Entity::delete_many()
471 .filter(workflow_run::Column::State.is_in([
472 WorkflowRunState::Success,
473 WorkflowRunState::Failure,
474 WorkflowRunState::Cancelled,
475 ]))
476 .exec(self.db().write())
477 .await?
478 .rows_affected
479 }
480 QUEUE_DELETION => {
481 user_deletion_requests::Entity::delete_many()
482 .filter(user_deletion_requests::Column::Status.is_in([
483 UserDeletionRequestStatus::Completed,
484 UserDeletionRequestStatus::Cancelled,
485 ]))
486 .exec(self.db().write())
487 .await?
488 .rows_affected
489 }
490 QUEUE_SYSTEM_TASKS => {
491 system_tasks::Entity::delete_many()
492 .filter(
493 system_tasks::Column::Status
494 .is_in([TaskStatus::Success, TaskStatus::Failed]),
495 )
496 .exec(self.db().write())
497 .await?
498 .rows_affected
499 }
463500 _ => {
464501 return Err(AdminError::BadRequest(format!(
465502 "queue {queue} does not support purge"
@@ -481,4 +518,4 @@
481518 affected,
482519 })
483520 }
484}
No newline at end of file
521}
Added lib/admin/src/services/runners.rs View lib/admin/src/services/runners.rs
@@ -0,0 +1,447 @@
1use models::ci::ci_environment;
2use models::ci::ci_secret::{self, CiSecretScope};
3use models::ci::runner::{self, RunnerState};
4use models::ci::runner_token;
5use models::ci::workflow_job;
6use models::ci::workflow_run::{self, WorkflowRunState};
7use sea_orm::sea_query::Expr;
8use sea_orm::{
9 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
10 QuerySelect, Set,
11};
12use serde::{Deserialize, Serialize};
13use time::OffsetDateTime;
14use utoipa::ToSchema;
15use uuid::Uuid;
16
17use crate::errors::AdminError;
18use crate::services::service::AdminService;
19
20#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
21pub struct RunnerDto {
22 pub id: Uuid,
23 pub name: String,
24 pub labels: Vec<String>,
25 pub capacity: i32,
26 pub version: Option<String>,
27 pub state: String,
28 pub space_id: Option<Uuid>,
29 pub active_jobs: i64,
30 pub last_seen_at: Option<OffsetDateTime>,
31 pub created_at: OffsetDateTime,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
35pub struct RunnerPageDto {
36 pub items: Vec<RunnerDto>,
37 pub total: u64,
38 pub limit: u64,
39 pub offset: u64,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
43pub struct UpdateRunnerCmd {
44 pub labels: Option<Vec<String>>,
45 pub capacity: Option<i32>,
46 pub enabled: Option<bool>,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
50pub struct RunnerTokenDto {
51 pub id: Uuid,
52 pub name: String,
53 pub space_id: Option<Uuid>,
54 pub single_use: bool,
55 pub used_at: Option<OffsetDateTime>,
56 pub revoked_at: Option<OffsetDateTime>,
57 pub expires_at: Option<OffsetDateTime>,
58 pub created_at: OffsetDateTime,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
62pub struct CreatedRunnerTokenDto {
63 pub token: RunnerTokenDto,
64 pub plaintext: String,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
68pub struct CreateRunnerTokenCmd {
69 pub name: String,
70 pub space_id: Option<Uuid>,
71 #[serde(default)]
72 pub expires_in_days: Option<i64>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
76pub struct EnvironmentAdminDto {
77 pub id: Uuid,
78 pub space_id: Uuid,
79 pub name: String,
80 pub description: Option<String>,
81 pub required_labels: Vec<String>,
82 pub secrets: Vec<String>,
83 pub created_at: OffsetDateTime,
84 pub updated_at: OffsetDateTime,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
88pub struct UpsertEnvironmentAdminCmd {
89 pub name: String,
90 #[serde(default)]
91 pub description: Option<String>,
92 #[serde(default)]
93 pub required_labels: Vec<String>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
97pub struct UpsertEnvironmentSecretCmd {
98 pub name: String,
99 pub value: String,
100}
101
102fn map_ci_error(error: ci::CiError) -> AdminError {
103 match error {
104 ci::CiError::NotFound => AdminError::NotFound,
105 ci::CiError::InvalidArgument(message) => AdminError::BadRequest(message),
106 ci::CiError::Conflict(message) => AdminError::Conflict(message),
107 ci::CiError::Unauthorized | ci::CiError::Forbidden => AdminError::Forbidden,
108 other => AdminError::Internal(other.to_string()),
109 }
110}
111
112impl RunnerDto {
113 fn from_model(model: runner::Model, active_jobs: i64) -> Self {
114 Self {
115 id: model.id,
116 name: model.name,
117 labels: serde_json::from_value(model.labels).unwrap_or_default(),
118 capacity: model.capacity,
119 version: model.version,
120 state: match model.state {
121 RunnerState::Active => "active",
122 RunnerState::Disabled => "disabled",
123 }
124 .to_string(),
125 space_id: model.space_id,
126 active_jobs,
127 last_seen_at: model.last_seen_at,
128 created_at: model.created_at,
129 }
130 }
131}
132
133fn token_dto(model: runner_token::Model) -> RunnerTokenDto {
134 RunnerTokenDto {
135 id: model.id,
136 name: model.name,
137 space_id: model.space_id,
138 single_use: model.single_use,
139 used_at: model.used_at,
140 revoked_at: model.revoked_at,
141 expires_at: model.expires_at,
142 created_at: model.created_at,
143 }
144}
145
146impl AdminService {
147 pub async fn list_runners(&self, limit: u64, offset: u64) -> Result<RunnerPageDto, AdminError> {
148 let limit = limit.clamp(1, 100);
149 let offset = offset.min(50_000);
150 let total = runner::Entity::find().count(self.db().read()).await?;
151 let rows = runner::Entity::find()
152 .order_by_desc(runner::Column::CreatedAt)
153 .limit(limit)
154 .offset(offset)
155 .all(self.db().read())
156 .await?;
157 let mut items = Vec::with_capacity(rows.len());
158 for row in rows {
159 let active = workflow_job::Entity::find()
160 .filter(workflow_job::Column::RunnerId.eq(row.id))
161 .filter(workflow_job::Column::State.is_in([
162 models::ci::workflow_job::WorkflowJobState::Claimed,
163 models::ci::workflow_job::WorkflowJobState::Running,
164 ]))
165 .count(self.db().read())
166 .await? as i64;
167 items.push(RunnerDto::from_model(row, active));
168 }
169 Ok(RunnerPageDto {
170 items,
171 total,
172 limit,
173 offset,
174 })
175 }
176
177 pub async fn update_runner(
178 &self,
179 id: Uuid,
180 cmd: &UpdateRunnerCmd,
181 ) -> Result<RunnerDto, AdminError> {
182 let row = runner::Entity::find_by_id(id)
183 .one(self.db().read())
184 .await?
185 .ok_or(AdminError::NotFound)?;
186 let mut am: runner::ActiveModel = row.into();
187 if let Some(labels) = &cmd.labels {
188 let labels: Vec<String> = labels
189 .iter()
190 .map(|l| l.trim().to_string())
191 .filter(|l| !l.is_empty())
192 .collect();
193 if labels.is_empty() {
194 return Err(AdminError::BadRequest(
195 "runner labels must not be empty".to_string(),
196 ));
197 }
198 am.labels = Set(serde_json::to_value(labels)
199 .map_err(|e| AdminError::Internal(format!("encoding labels failed: {e}")))?);
200 }
201 if let Some(capacity) = cmd.capacity {
202 if capacity <= 0 || capacity > 256 {
203 return Err(AdminError::BadRequest(
204 "runner capacity must be between 1 and 256".to_string(),
205 ));
206 }
207 am.capacity = Set(capacity);
208 }
209 if let Some(enabled) = cmd.enabled {
210 am.state = Set(if enabled {
211 RunnerState::Active
212 } else {
213 RunnerState::Disabled
214 });
215 }
216 am.updated_at = Set(OffsetDateTime::now_utc());
217 let updated = am.update(self.db().write()).await?;
218 Ok(RunnerDto::from_model(updated, 0))
219 }
220
221 pub async fn delete_runner(&self, id: Uuid) -> Result<(), AdminError> {
222 let _ = runner_token::Entity::delete_many()
223 .filter(runner_token::Column::RunnerId.eq(id))
224 .exec(self.db().write())
225 .await;
226 let deleted = runner::Entity::delete_by_id(id)
227 .exec(self.db().write())
228 .await?;
229 if deleted.rows_affected == 0 {
230 return Err(AdminError::NotFound);
231 }
232 Ok(())
233 }
234
235 pub async fn list_runner_tokens(&self) -> Result<Vec<RunnerTokenDto>, AdminError> {
236 let rows = runner_token::Entity::find()
237 .filter(runner_token::Column::RunnerId.is_null())
238 .order_by_desc(runner_token::Column::CreatedAt)
239 .all(self.db().read())
240 .await?;
241 Ok(rows.into_iter().map(token_dto).collect())
242 }
243
244 pub async fn create_runner_token(
245 &self,
246 cmd: &CreateRunnerTokenCmd,
247 ) -> Result<CreatedRunnerTokenDto, AdminError> {
248 let name = cmd.name.trim();
249 if name.is_empty() || name.len() > 200 {
250 return Err(AdminError::BadRequest(
251 "token name must be 1-200 characters".to_string(),
252 ));
253 }
254 let id = Uuid::now_v7();
255 let (credential, hash, salt, last_eight) = ci::runners::generate_credential(id);
256 let secret = credential
257 .split_once('.')
258 .map(|(_, secret)| secret.to_string())
259 .unwrap_or_default();
260 let token = format!("{id}.{secret}");
261 let now = OffsetDateTime::now_utc();
262 let expires_at = cmd
263 .expires_in_days
264 .filter(|days| *days > 0)
265 .map(|days| now + time::Duration::days(days));
266 let model = runner_token::ActiveModel {
267 id: Set(id),
268 runner_id: Set(None),
269 name: Set(name.to_string()),
270 token_hash: Set(hash),
271 token_salt: Set(salt),
272 token_last_eight: Set(last_eight),
273 space_id: Set(cmd.space_id),
274 single_use: Set(true),
275 used_at: Set(None),
276 revoked_at: Set(None),
277 expires_at: Set(expires_at),
278 created_at: Set(now),
279 updated_at: Set(now),
280 }
281 .insert(self.db().write())
282 .await?;
283 Ok(CreatedRunnerTokenDto {
284 token: token_dto(model),
285 plaintext: token,
286 })
287 }
288
289 pub async fn revoke_runner_token(&self, id: Uuid) -> Result<(), AdminError> {
290 let row = runner_token::Entity::find_by_id(id)
291 .one(self.db().read())
292 .await?
293 .ok_or(AdminError::NotFound)?;
294 let mut am: runner_token::ActiveModel = row.into();
295 am.revoked_at = Set(Some(OffsetDateTime::now_utc()));
296 am.updated_at = Set(OffsetDateTime::now_utc());
297 am.update(self.db().write()).await?;
298 Ok(())
299 }
300
301 pub async fn list_environments(
302 &self,
303 space_id: Uuid,
304 ) -> Result<Vec<EnvironmentAdminDto>, AdminError> {
305 let rows = ci_environment::Entity::find()
306 .filter(ci_environment::Column::SpaceId.eq(space_id))
307 .order_by_asc(ci_environment::Column::Name)
308 .all(self.db().read())
309 .await?;
310 let mut out = Vec::with_capacity(rows.len());
311 for row in rows {
312 let secrets = ci_secret::Entity::find()
313 .filter(ci_secret::Column::EnvironmentId.eq(row.id))
314 .filter(ci_secret::Column::Scope.eq(CiSecretScope::Environment))
315 .all(self.db().read())
316 .await?
317 .into_iter()
318 .map(|s| s.name)
319 .collect();
320 out.push(EnvironmentAdminDto {
321 id: row.id,
322 space_id: row.space_id,
323 name: row.name,
324 description: row.description,
325 required_labels: serde_json::from_value(row.required_labels).unwrap_or_default(),
326 secrets,
327 created_at: row.created_at,
328 updated_at: row.updated_at,
329 });
330 }
331 Ok(out)
332 }
333
334 pub async fn upsert_environment(
335 &self,
336 space_id: Uuid,
337 cmd: &UpsertEnvironmentAdminCmd,
338 ) -> Result<EnvironmentAdminDto, AdminError> {
339 let service = ci::CiService::new(self.state.clone());
340 service
341 .upsert_environment(
342 space_id,
343 &cmd.name,
344 cmd.description.clone(),
345 cmd.required_labels.clone(),
346 )
347 .await
348 .map_err(map_ci_error)?;
349 let list = self.list_environments(space_id).await?;
350 list.into_iter()
351 .find(|e| e.name == cmd.name.trim())
352 .ok_or(AdminError::NotFound)
353 }
354
355 pub async fn delete_environment(&self, id: Uuid) -> Result<(), AdminError> {
356 let service = ci::CiService::new(self.state.clone());
357 service.delete_environment(id).await.map_err(map_ci_error)?;
358 Ok(())
359 }
360
361 pub async fn upsert_environment_secret(
362 &self,
363 environment_id: Uuid,
364 cmd: &UpsertEnvironmentSecretCmd,
365 actor: Option<Uuid>,
366 ) -> Result<(), AdminError> {
367 let service = ci::CiService::new(self.state.clone());
368 service
369 .upsert_environment_secret(environment_id, &cmd.name, &cmd.value, actor)
370 .await
371 .map_err(map_ci_error)?;
372 Ok(())
373 }
374
375 pub async fn delete_environment_secret(
376 &self,
377 environment_id: Uuid,
378 name: &str,
379 ) -> Result<(), AdminError> {
380 let row = ci_secret::Entity::find()
381 .filter(ci_secret::Column::EnvironmentId.eq(environment_id))
382 .filter(ci_secret::Column::Name.eq(name.to_string()))
383 .one(self.db().read())
384 .await?;
385 let Some(row) = row else {
386 return Err(AdminError::NotFound);
387 };
388 ci_secret::Entity::delete_by_id(row.id)
389 .exec(self.db().write())
390 .await?;
391 Ok(())
392 }
393
394 pub async fn pipeline_run_counts(&self) -> Result<(i64, i64, i64, i64), AdminError> {
395 let queued = workflow_run::Entity::find()
396 .filter(workflow_run::Column::State.eq(WorkflowRunState::Queued))
397 .count(self.db().read())
398 .await? as i64;
399 let running = workflow_run::Entity::find()
400 .filter(workflow_run::Column::State.eq(WorkflowRunState::Running))
401 .count(self.db().read())
402 .await? as i64;
403 let failed = workflow_run::Entity::find()
404 .filter(workflow_run::Column::State.eq(WorkflowRunState::Failure))
405 .count(self.db().read())
406 .await? as i64;
407 let succeeded = workflow_run::Entity::find()
408 .filter(workflow_run::Column::State.eq(WorkflowRunState::Success))
409 .count(self.db().read())
410 .await? as i64;
411 Ok((queued, running, failed, succeeded))
412 }
413
414 #[allow(dead_code)]
415 fn _touch(_expr: sea_orm::sea_query::SimpleExpr) {
416 let _ = Expr::value(0);
417 }
418}
419
420impl AdminService {
421 pub async fn environment_space(&self, id: Uuid) -> Result<Uuid, AdminError> {
422 let row = ci_environment::Entity::find_by_id(id)
423 .one(self.db().read())
424 .await?
425 .ok_or(AdminError::NotFound)?;
426 Ok(row.space_id)
427 }
428}
429
430impl AdminService {
431 pub async fn audit_action(
432 &self,
433 actor: &crate::share::roles::AdminActor,
434 action: &str,
435 detail: serde_json::Value,
436 ctx: &crate::services::audit::AuditCtx,
437 ) {
438 let record = crate::services::audit::AuditRecord {
439 action,
440 target: None,
441 detail,
442 };
443 if let Err(error) = self.audit(Some(actor), record, ctx).await {
444 tracing::warn!(%error, action, "admin audit write failed");
445 }
446 }
447}
Modified lib/admin/src/services/service.rs View lib/admin/src/services/service.rs
@@ -41,4 +41,4 @@
4141 pub fn uptime_secs(&self) -> u64 {
4242 self.started_at.elapsed().as_secs()
4343 }
44}
No newline at end of file
44}
Modified lib/admin/src/services/spaces.rs View lib/admin/src/services/spaces.rs
@@ -421,7 +421,9 @@
421421 select.filter(space_members::Column::Status.eq(MemberStatus::Inactive))
422422 }
423423 Some("left") => select.filter(space_members::Column::Status.eq(MemberStatus::Left)),
424 Some("removed") => select.filter(space_members::Column::Status.eq(MemberStatus::Removed)),
424 Some("removed") => {
425 select.filter(space_members::Column::Status.eq(MemberStatus::Removed))
426 }
425427 _ => select,
426428 };
427429 select = select.order_by_desc(space_members::Column::JoinedAt);
@@ -549,9 +551,7 @@
549551 if let Some(alias) = cmd.alias_name.as_deref()
550552 && alias.chars().count() > self.config().space.max_alias_name_len
551553 {
552 return Err(AdminError::BadRequest(
553 "alias name is too long".to_string(),
554 ));
554 return Err(AdminError::BadRequest("alias name is too long".to_string()));
555555 }
556556 let existing = space_members::Entity::find_by_id((space_id, cmd.user_id))
557557 .one(self.db().read())
@@ -641,4 +641,4 @@
641641 }
642642 }
643643 }
644}
No newline at end of file
644}
Modified lib/admin/src/services/storage.rs View lib/admin/src/services/storage.rs
@@ -222,9 +222,6 @@
222222 ctx,
223223 )
224224 .await?;
225 Ok(DeleteObjectResp {
226 key,
227 deleted: true,
228 })
225 Ok(DeleteObjectResp { key, deleted: true })
229226 }
230}
No newline at end of file
227}
Modified lib/admin/src/services/system.rs View lib/admin/src/services/system.rs
@@ -116,7 +116,10 @@
116116 .query_async::<String>(connection)
117117 .await
118118 .is_ok();
119 let info = redis::cmd("INFO").query_async::<String>(connection).await.ok();
119 let info = redis::cmd("INFO")
120 .query_async::<String>(connection)
121 .await
122 .ok();
120123 (ping, info)
121124 }
122125 export::RedisConnection::Cluster(connection) => {
@@ -124,7 +127,10 @@
124127 .query_async::<String>(connection)
125128 .await
126129 .is_ok();
127 let info = redis::cmd("INFO").query_async::<String>(connection).await.ok();
130 let info = redis::cmd("INFO")
131 .query_async::<String>(connection)
132 .await
133 .ok();
128134 (ping, info)
129135 }
130136 };
@@ -153,10 +159,7 @@
153159 Ok(ok) => DependencyDto {
154160 ok,
155161 latency_ms: Some(latency_ms(start)),
156 detail: Some(format!(
157 "bucket={}",
158 self.storage().client().bucket_name()
159 )),
162 detail: Some(format!("bucket={}", self.storage().client().bucket_name())),
160163 },
161164 Err(error) => DependencyDto {
162165 ok: false,
@@ -462,11 +465,7 @@
462465 path_style: config.s3.path_style,
463466 },
464467 database: DatabaseConfigDto {
465 endpoint: config
466 .database
467 .url
468 .as_deref()
469 .map(redact_endpoint),
468 endpoint: config.database.url.as_deref().map(redact_endpoint),
470469 host: config.database.host.clone(),
471470 port: config.database.port,
472471 database: config.database.database.clone(),
@@ -546,7 +545,9 @@
546545 let counts = counts?;
547546 let queues = queues?;
548547 let now = OffsetDateTime::now_utc();
549 let since = (now.date() - time::Duration::days(13)).midnight().assume_utc();
548 let since = (now.date() - time::Duration::days(13))
549 .midnight()
550 .assume_utc();
550551
551552 let users = user::Entity::find()
552553 .filter(user::Column::CreatedAt.gte(since))
Modified lib/admin/src/services/tasks.rs View lib/admin/src/services/tasks.rs
@@ -1,13 +1,13 @@
11use models::admin::admin_user::AdminRole;
22use models::chat::webhook_deliveries::{self, WebhookDeliveryStatus};
3use models::ci::workflow_run::{self, WorkflowRunState};
34use models::import::job;
4use models::repo::pipeline_run::{self, PipelineRunState};
55use models::search::index_job;
66use models::system::system_tasks::{self, TaskStatus};
77use models::users::user_deletion_requests::{self, UserDeletionRequestStatus};
88use sea_orm::{
9 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
10 Set,
9 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
10 QuerySelect, Set,
1111};
1212use serde::{Deserialize, Serialize};
1313use serde_json::{Value, json};
@@ -71,14 +71,14 @@
7171 pub state: String,
7272}
7373
74fn pipeline_state_str(state: PipelineRunState) -> String {
74fn pipeline_state_str(state: WorkflowRunState) -> String {
7575 match state {
76 PipelineRunState::Queued => "queued",
77 PipelineRunState::Running => "running",
78 PipelineRunState::Success => "success",
79 PipelineRunState::Failure => "failure",
80 PipelineRunState::CancelRequested => "cancel_requested",
81 PipelineRunState::Cancelled => "cancelled",
76 WorkflowRunState::Queued => "queued",
77 WorkflowRunState::Running => "running",
78 WorkflowRunState::Success => "success",
79 WorkflowRunState::Failure => "failure",
80 WorkflowRunState::CancelRequested => "cancel_requested",
81 WorkflowRunState::Cancelled => "cancelled",
8282 }
8383 .to_string()
8484}
@@ -117,14 +117,14 @@
117117 sha.chars().take(8).collect()
118118}
119119
120fn parse_pipeline_state(value: &str) -> Option<PipelineRunState> {
120fn parse_pipeline_state(value: &str) -> Option<WorkflowRunState> {
121121 match value {
122 "queued" => Some(PipelineRunState::Queued),
123 "running" => Some(PipelineRunState::Running),
124 "success" => Some(PipelineRunState::Success),
125 "failure" => Some(PipelineRunState::Failure),
126 "cancel_requested" => Some(PipelineRunState::CancelRequested),
127 "cancelled" => Some(PipelineRunState::Cancelled),
122 "queued" => Some(WorkflowRunState::Queued),
123 "running" => Some(WorkflowRunState::Running),
124 "success" => Some(WorkflowRunState::Success),
125 "failure" => Some(WorkflowRunState::Failure),
126 "cancel_requested" => Some(WorkflowRunState::CancelRequested),
127 "cancelled" => Some(WorkflowRunState::Cancelled),
128128 _ => None,
129129 }
130130}
@@ -186,8 +186,14 @@
186186 let mut tasks: Vec<TaskDto> = Vec::new();
187187 for source in self.requested_sources(&query.source) {
188188 tasks.extend(
189 self.fetch_source(source, fetch_limit, query.state.as_deref(), query.from, query.to)
190 .await?,
189 self.fetch_source(
190 source,
191 fetch_limit,
192 query.state.as_deref(),
193 query.from,
194 query.to,
195 )
196 .await?,
191197 );
192198 }
193199 if let Some(needle) = query
@@ -289,11 +295,11 @@
289295 let mut select = webhook_deliveries::Entity::find()
290296 .order_by_desc(webhook_deliveries::Column::CreatedAt);
291297 if let Some(state) = state {
292 select = select.filter(
293 webhook_deliveries::Column::Status.eq(parse_webhook_state(state).ok_or_else(
294 || AdminError::BadRequest("invalid webhook state".to_string()),
295 )?),
296 );
298 select = select.filter(webhook_deliveries::Column::Status.eq(
299 parse_webhook_state(state).ok_or_else(|| {
300 AdminError::BadRequest("invalid webhook state".to_string())
301 })?,
302 ));
297303 }
298304 if let Some(from) = from {
299305 select = select.filter(webhook_deliveries::Column::CreatedAt.gte(from));
@@ -316,25 +322,29 @@
316322 }
317323 SOURCE_PIPELINE => {
318324 let mut select =
319 pipeline_run::Entity::find().order_by_desc(pipeline_run::Column::CreatedAt);
325 workflow_run::Entity::find().order_by_desc(workflow_run::Column::CreatedAt);
320326 if let Some(state) = state {
321 select = select.filter(
322 pipeline_run::Column::State.eq(parse_pipeline_state(state).ok_or_else(
323 || AdminError::BadRequest("invalid pipeline state".to_string()),
324 )?),
325 );
327 select = select.filter(workflow_run::Column::State.eq(
328 parse_pipeline_state(state).ok_or_else(|| {
329 AdminError::BadRequest("invalid pipeline state".to_string())
330 })?,
331 ));
326332 }
327333 if let Some(from) = from {
328 select = select.filter(pipeline_run::Column::CreatedAt.gte(from));
334 select = select.filter(workflow_run::Column::CreatedAt.gte(from));
329335 }
330336 if let Some(to) = to {
331 select = select.filter(pipeline_run::Column::CreatedAt.lte(to));
337 select = select.filter(workflow_run::Column::CreatedAt.lte(to));
332338 }
333339 for row in select.limit(fetch_limit).all(self.db().read()).await? {
334340 tasks.push(TaskDto {
335341 id: row.id,
336342 source: SOURCE_PIPELINE.to_string(),
337 title: format!("run {}@{}", short_sha(&row.commit_sha), row.branch),
343 title: format!(
344 "run {}@{}",
345 short_sha(&row.commit_sha),
346 row.branch.clone().unwrap_or_else(|| row.git_ref.clone())
347 ),
338348 state: pipeline_state_str(row.state),
339349 created_at: row.created_at,
340350 updated_at: Some(row.updated_at),
@@ -347,13 +357,11 @@
347357 let mut select = user_deletion_requests::Entity::find()
348358 .order_by_desc(user_deletion_requests::Column::CreatedAt);
349359 if let Some(state) = state {
350 select = select.filter(
351 user_deletion_requests::Column::Status.eq(
352 parse_deletion_state(state).ok_or_else(|| {
353 AdminError::BadRequest("invalid deletion state".to_string())
354 })?,
355 ),
356 );
360 select = select.filter(user_deletion_requests::Column::Status.eq(
361 parse_deletion_state(state).ok_or_else(|| {
362 AdminError::BadRequest("invalid deletion state".to_string())
363 })?,
364 ));
357365 }
358366 if let Some(from) = from {
359367 select = select.filter(user_deletion_requests::Column::CreatedAt.gte(from));
@@ -378,11 +386,11 @@
378386 let mut select =
379387 system_tasks::Entity::find().order_by_desc(system_tasks::Column::CreatedAt);
380388 if let Some(state) = state {
381 select = select.filter(
382 system_tasks::Column::Status.eq(parse_task_status(state).ok_or_else(|| {
389 select = select.filter(system_tasks::Column::Status.eq(
390 parse_task_status(state).ok_or_else(|| {
383391 AdminError::BadRequest("invalid task state".to_string())
384 })?),
385 );
392 })?,
393 ));
386394 }
387395 if let Some(from) = from {
388396 select = select.filter(system_tasks::Column::CreatedAt.gte(from));
@@ -495,7 +503,7 @@
495503 (task, detail)
496504 }
497505 SOURCE_PIPELINE => {
498 let row = pipeline_run::Entity::find_by_id(id)
506 let row = workflow_run::Entity::find_by_id(id)
499507 .one(self.db().read())
500508 .await?
501509 .ok_or(AdminError::NotFound)?;
@@ -509,7 +517,11 @@
509517 let task = TaskDto {
510518 id: row.id,
511519 source: SOURCE_PIPELINE.to_string(),
512 title: format!("run {}@{}", short_sha(&row.commit_sha), row.branch),
520 title: format!(
521 "run {}@{}",
522 short_sha(&row.commit_sha),
523 row.branch.clone().unwrap_or_else(|| row.git_ref.clone())
524 ),
513525 state: pipeline_state_str(row.state),
514526 created_at: row.created_at,
515527 updated_at: Some(row.updated_at),
@@ -630,20 +642,20 @@
630642 "pending".to_string()
631643 }
632644 SOURCE_PIPELINE => {
633 let row = pipeline_run::Entity::find_by_id(id)
645 let row = workflow_run::Entity::find_by_id(id)
634646 .one(self.db().read())
635647 .await?
636648 .ok_or(AdminError::NotFound)?;
637649 if !matches!(
638650 row.state,
639 PipelineRunState::Failure | PipelineRunState::Cancelled
651 WorkflowRunState::Failure | WorkflowRunState::Cancelled
640652 ) {
641653 return Err(AdminError::Conflict(
642654 "only failed or cancelled pipeline runs can be retried".to_string(),
643655 ));
644656 }
645 let mut am: pipeline_run::ActiveModel = row.into();
646 am.state = Set(PipelineRunState::Queued);
657 let mut am: workflow_run::ActiveModel = row.into();
658 am.state = Set(WorkflowRunState::Queued);
647659 am.started_at = Set(None);
648660 am.finished_at = Set(None);
649661 am.updated_at = Set(now);
@@ -777,17 +789,20 @@
777789 "failed".to_string()
778790 }
779791 SOURCE_PIPELINE => {
780 let row = pipeline_run::Entity::find_by_id(id)
792 let row = workflow_run::Entity::find_by_id(id)
781793 .one(self.db().read())
782794 .await?
783795 .ok_or(AdminError::NotFound)?;
784 if !matches!(row.state, PipelineRunState::Queued | PipelineRunState::Running) {
796 if !matches!(
797 row.state,
798 WorkflowRunState::Queued | WorkflowRunState::Running
799 ) {
785800 return Err(AdminError::Conflict(
786801 "only queued or running pipeline runs can be cancelled".to_string(),
787802 ));
788803 }
789 let mut am: pipeline_run::ActiveModel = row.into();
790 am.state = Set(PipelineRunState::CancelRequested);
804 let mut am: workflow_run::ActiveModel = row.into();
805 am.state = Set(WorkflowRunState::CancelRequested);
791806 am.updated_at = Set(now);
792807 am.update(self.db().write()).await?;
793808 "cancel_requested".to_string()
@@ -882,15 +897,15 @@
882897 )
883898 }
884899 SOURCE_PIPELINE => {
885 let row = pipeline_run::Entity::find_by_id(id)
900 let row = workflow_run::Entity::find_by_id(id)
886901 .one(self.db().read())
887902 .await?
888903 .ok_or(AdminError::NotFound)?;
889904 matches!(
890905 row.state,
891 PipelineRunState::Success
892 | PipelineRunState::Failure
893 | PipelineRunState::Cancelled
906 WorkflowRunState::Success
907 | WorkflowRunState::Failure
908 | WorkflowRunState::Cancelled
894909 )
895910 }
896911 SOURCE_DELETION => {
@@ -919,7 +934,9 @@
919934 }
920935 match source {
921936 SOURCE_IMPORT => {
922 job::Entity::delete_by_id(id).exec(self.db().write()).await?;
937 job::Entity::delete_by_id(id)
938 .exec(self.db().write())
939 .await?;
923940 }
924941 SOURCE_SEARCH_INDEX => {
925942 index_job::Entity::delete_by_id(id)
@@ -932,7 +949,7 @@
932949 .await?;
933950 }
934951 SOURCE_PIPELINE => {
935 pipeline_run::Entity::delete_by_id(id)
952 workflow_run::Entity::delete_by_id(id)
936953 .exec(self.db().write())
937954 .await?;
938955 }
@@ -1043,12 +1060,15 @@
10431060 SOURCE_CHAT_WEBHOOK => TaskSourceStatDto {
10441061 source: source.to_string(),
10451062 queued: webhook_deliveries::Entity::find()
1046 .filter(webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Pending))
1063 .filter(
1064 webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Pending),
1065 )
10471066 .count(self.db().read())
10481067 .await? as i64,
10491068 running: webhook_deliveries::Entity::find()
10501069 .filter(
1051 webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Delivering),
1070 webhook_deliveries::Column::Status
1071 .eq(WebhookDeliveryStatus::Delivering),
10521072 )
10531073 .count(self.db().read())
10541074 .await? as i64,
@@ -1057,36 +1077,38 @@
10571077 .count(self.db().read())
10581078 .await? as i64,
10591079 failed: webhook_deliveries::Entity::find()
1060 .filter(webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Failed))
1080 .filter(
1081 webhook_deliveries::Column::Status.eq(WebhookDeliveryStatus::Failed),
1082 )
10611083 .count(self.db().read())
10621084 .await? as i64,
10631085 },
10641086 SOURCE_PIPELINE => TaskSourceStatDto {
10651087 source: source.to_string(),
1066 queued: pipeline_run::Entity::find()
1067 .filter(pipeline_run::Column::State.eq(PipelineRunState::Queued))
1088 queued: workflow_run::Entity::find()
1089 .filter(workflow_run::Column::State.eq(WorkflowRunState::Queued))
10681090 .count(self.db().read())
10691091 .await? as i64,
1070 running: pipeline_run::Entity::find()
1071 .filter(pipeline_run::Column::State.eq(PipelineRunState::Running))
1092 running: workflow_run::Entity::find()
1093 .filter(workflow_run::Column::State.eq(WorkflowRunState::Running))
10721094 .count(self.db().read())
10731095 .await? as i64,
1074 succeeded: pipeline_run::Entity::find()
1075 .filter(pipeline_run::Column::State.eq(PipelineRunState::Success))
1096 succeeded: workflow_run::Entity::find()
1097 .filter(workflow_run::Column::State.eq(WorkflowRunState::Success))
10761098 .count(self.db().read())
10771099 .await? as i64,
1078 failed: pipeline_run::Entity::find()
1079 .filter(pipeline_run::Column::State.eq(PipelineRunState::Failure))
1100 failed: workflow_run::Entity::find()
1101 .filter(workflow_run::Column::State.eq(WorkflowRunState::Failure))
10801102 .count(self.db().read())
10811103 .await? as i64,
10821104 },
10831105 SOURCE_DELETION => TaskSourceStatDto {
10841106 source: source.to_string(),
10851107 queued: user_deletion_requests::Entity::find()
1086 .filter(
1087 user_deletion_requests::Column::Status
1088 .is_in([UserDeletionRequestStatus::Pending, UserDeletionRequestStatus::Scheduled]),
1089 )
1108 .filter(user_deletion_requests::Column::Status.is_in([
1109 UserDeletionRequestStatus::Pending,
1110 UserDeletionRequestStatus::Scheduled,
1111 ]))
10901112 .count(self.db().read())
10911113 .await? as i64,
10921114 running: 0,
Modified lib/admin/src/services/users.rs View lib/admin/src/services/users.rs
@@ -1,16 +1,16 @@
11use std::collections::HashSet;
22
3use export::pagination::Page;
43use auth::share::crypto::{hash_password, validate_password};
54use auth::share::validate::{validate_email, validate_username};
5use export::pagination::Page;
6use models::space::spaces;
67use models::users::user::ProfileVisibility;
78use models::users::user_sessions::SessionKind;
89use models::users::user_suspensions::{self, SuspensionType};
910use models::users::{
10 user, user_access_keys, user_deletion_requests, user_emails, user_profiles, user_recovery_codes,
11 user_sessions, user_ssh_keys,
11 user, user_access_keys, user_deletion_requests, user_emails, user_profiles,
12 user_recovery_codes, user_sessions, user_ssh_keys,
1213};
13use models::space::spaces;
1414use sea_orm::sea_query::Expr;
1515use sea_orm::{
1616 ActiveModelTrait, ColumnTrait, Condition, DbErr, EntityTrait, IntoActiveModel, PaginatorTrait,
@@ -155,7 +155,10 @@
155155}
156156
157157impl AdminService {
158 pub async fn list_users(&self, query: ListUsersQuery) -> Result<Page<AdminUserSummary>, AdminError> {
158 pub async fn list_users(
159 &self,
160 query: ListUsersQuery,
161 ) -> Result<Page<AdminUserSummary>, AdminError> {
159162 let limit = query.limit.unwrap_or(50).clamp(1, 100);
160163 let offset = query.offset.unwrap_or(0);
161164 let select = self.users_select(&query).await?;
@@ -446,9 +449,7 @@
446449
447450 let remaining = self.active_suspension_ids(&[uid]).await?;
448451 if !remaining.contains(&uid)
449 && let Some(user_row) = user::Entity::find_by_id(uid)
450 .one(self.db().write())
451 .await?
452 && let Some(user_row) = user::Entity::find_by_id(uid).one(self.db().write()).await?
452453 {
453454 let mut um: user::ActiveModel = user_row.into();
454455 um.is_restricted = Set(false);
@@ -477,7 +478,10 @@
477478 ctx: &AuditCtx,
478479 ) -> Result<(), AdminError> {
479480 actor.require(AdminRole::Operator)?;
480 auth::share::crypto::validate_password(&cmd.new_password, &self.config().auth.password_policy)?;
481 auth::share::crypto::validate_password(
482 &cmd.new_password,
483 &self.config().auth.password_policy,
484 )?;
481485 let row = user::Entity::find_by_id(uid)
482486 .one(self.db().read())
483487 .await?
@@ -833,7 +837,8 @@
833837 .ok_or(AdminError::NotFound)?;
834838 let limit = query.limit.unwrap_or(50).clamp(1, 200);
835839 let offset = query.offset.unwrap_or(0);
836 let mut select = user_sessions::Entity::find().filter(user_sessions::Column::UserId.eq(uid));
840 let mut select =
841 user_sessions::Entity::find().filter(user_sessions::Column::UserId.eq(uid));
837842 if let Some(raw) = query
838843 .kind
839844 .as_deref()
@@ -1141,8 +1146,8 @@
11411146 actor.require(AdminRole::Operator)?;
11421147 let action = cmd.action.trim().to_ascii_lowercase();
11431148 match action.as_str() {
1144 "activate" | "deactivate" | "restrict" | "unrestrict" | "revoke_sessions" | "suspend"
1145 | "delete" => {}
1149 "activate" | "deactivate" | "restrict" | "unrestrict" | "revoke_sessions"
1150 | "suspend" | "delete" => {}
11461151 other => {
11471152 return Err(AdminError::BadRequest(format!(
11481153 "unsupported bulk action: {other}"
@@ -1279,4 +1284,4 @@
12791284 failed,
12801285 })
12811286 }
1282}
No newline at end of file
1287}
Modified lib/admin/src/share/guard.rs View lib/admin/src/share/guard.rs
@@ -53,4 +53,4 @@
5353 path.ends_with("/admin/me")
5454 || path.ends_with("/admin/me/password")
5555 || path.ends_with("/admin/auth/logout")
56}
No newline at end of file
56}
Modified lib/admin/src/share/mod.rs View lib/admin/src/share/mod.rs
@@ -1,3 +1,3 @@
11pub mod guard;
22pub mod roles;
3pub mod session;
No newline at end of file
3pub mod session;
Modified lib/admin/src/share/roles.rs View lib/admin/src/share/roles.rs
@@ -51,4 +51,4 @@
5151 .map(CurrentAdmin)
5252 .ok_or_else(AdminApiError::unauthorized)
5353 }
54}
No newline at end of file
54}
Modified lib/admin/src/share/session.rs View lib/admin/src/share/session.rs
@@ -26,4 +26,4 @@
2626 session
2727 .get::<i64>(ADMIN_PWD_VERSION_KEY)
2828 .is_some_and(|value| value == current)
29}
No newline at end of file
29}
Modified lib/admin/src/errors.rs View lib/admin/src/errors.rs
@@ -107,4 +107,4 @@
107107 AdminError::Internal(message) => Self::internal(message),
108108 }
109109 }
110}
No newline at end of file
110}
Modified lib/admin/src/lib.rs View lib/admin/src/lib.rs
@@ -6,4 +6,4 @@
66pub mod share;
77
88pub use errors::AdminApiError;
9pub use services::service::AdminService;
No newline at end of file
9pub use services::service::AdminService;
Modified lib/admin/src/openapi.rs View lib/admin/src/openapi.rs
@@ -71,6 +71,17 @@
7171 crate::handlers::tasks::delete_task,
7272 crate::handlers::tasks::task_stats,
7373 crate::handlers::tasks::bulk_tasks,
74 crate::handlers::runners::list_runners,
75 crate::handlers::runners::update_runner,
76 crate::handlers::runners::delete_runner,
77 crate::handlers::runners::list_runner_tokens,
78 crate::handlers::runners::create_runner_token,
79 crate::handlers::runners::revoke_runner_token,
80 crate::handlers::runners::list_environments,
81 crate::handlers::runners::upsert_environment,
82 crate::handlers::runners::delete_environment,
83 crate::handlers::runners::upsert_environment_secret,
84 crate::handlers::runners::delete_environment_secret,
7485 crate::handlers::logs::list_audit_logs,
7586 crate::handlers::logs::list_job_logs,
7687 crate::handlers::logs::export_audit_logs,
@@ -79,6 +90,15 @@
7990 crate::handlers::system::dashboard
8091 ),
8192 components(schemas(
93 crate::services::runners::RunnerDto,
94 crate::services::runners::RunnerPageDto,
95 crate::services::runners::UpdateRunnerCmd,
96 crate::services::runners::RunnerTokenDto,
97 crate::services::runners::CreatedRunnerTokenDto,
98 crate::services::runners::CreateRunnerTokenCmd,
99 crate::services::runners::EnvironmentAdminDto,
100 crate::services::runners::UpsertEnvironmentAdminCmd,
101 crate::services::runners::UpsertEnvironmentSecretCmd,
82102 export::rest::NoData,
83103 export::pagination::PageQuery,
84104 models::admin::admin_user::AdminRole,
@@ -193,11 +213,9 @@
193213 SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::new("gitdata_admin_session"))),
194214 );
195215 }
196 doc.security = Some(vec![
197 utoipa::openapi::security::SecurityRequirement::new(
198 "admin_session_cookie",
199 Vec::<String>::new(),
200 ),
201 ]);
216 doc.security = Some(vec![utoipa::openapi::security::SecurityRequirement::new(
217 "admin_session_cookie",
218 Vec::<String>::new(),
219 )]);
202220 doc
203}
No newline at end of file
221}
Modified lib/admin/src/routes.rs View lib/admin/src/routes.rs
@@ -1,7 +1,9 @@
11use axum::Router;
2use axum::routing::{delete, get, patch, post};
2use axum::routing::{delete, get, patch, post, put};
33
4use crate::handlers::{admins, auth, chat, logs, queues, spaces, storage, system, tasks, users};
4use crate::handlers::{
5 admins, auth, chat, logs, queues, runners, spaces, storage, system, tasks, users,
6};
57use crate::services::service::AdminService;
68use crate::share::guard::admin_guard;
79
@@ -11,6 +13,35 @@
1113 .route("/admin/auth/logout", post(auth::logout));
1214
1315 let protected = Router::new()
16 .route("/admin/runners", get(runners::list_runners))
17 .route(
18 "/admin/runners/{id}",
19 patch(runners::update_runner).delete(runners::delete_runner),
20 )
21 .route(
22 "/admin/runner-tokens",
23 get(runners::list_runner_tokens).post(runners::create_runner_token),
24 )
25 .route(
26 "/admin/runner-tokens/{id}/revoke",
27 post(runners::revoke_runner_token),
28 )
29 .route(
30 "/admin/spaces/{id}/environments",
31 get(runners::list_environments).put(runners::upsert_environment),
32 )
33 .route(
34 "/admin/environments/{id}",
35 delete(runners::delete_environment),
36 )
37 .route(
38 "/admin/environments/{id}/secrets",
39 put(runners::upsert_environment_secret),
40 )
41 .route(
42 "/admin/environments/{id}/secrets/{name}",
43 delete(runners::delete_environment_secret),
44 )
1445 .route("/admin/me", get(auth::me))
1546 .route("/admin/me/password", patch(auth::change_password))
1647 .route(
@@ -64,11 +95,8 @@
6495 "/admin/users/{uid}/sessions/{session_id}",
6596 delete(users::revoke_user_session),
6697 )
98 .route("/admin/users/{uid}/security", get(users::get_user_security))
6799 .route(
68 "/admin/users/{uid}/security",
69 get(users::get_user_security),
70 )
71 .route(
72100 "/admin/users/{uid}/two-factor",
73101 delete(users::clear_user_two_factor),
74102 )
@@ -80,10 +108,7 @@
80108 .patch(chat::update_room)
81109 .delete(chat::delete_room),
82110 )
83 .route(
84 "/admin/chat/rooms/{id}/members",
85 get(chat::list_members),
86 )
111 .route("/admin/chat/rooms/{id}/members", get(chat::list_members))
87112 .route(
88113 "/admin/chat/rooms/{id}/members/{uid}",
89114 delete(chat::remove_member),
@@ -154,4 +179,4 @@
154179 ));
155180
156181 public.merge(protected)
157}
No newline at end of file
182}
Modified lib/admin/Cargo.toml View lib/admin/Cargo.toml
@@ -9,6 +9,7 @@
99config = { workspace = true }
1010export = { workspace = true }
1111models = { workspace = true }
12ci = { workspace = true }
1213redis = { workspace = true }
1314reqwest = { workspace = true }
1415sea-orm = { workspace = true }
@@ -20,4 +21,4 @@
2021time = { workspace = true }
2122tracing = { workspace = true }
2223utoipa = { workspace = true }
23uuid = { workspace = true }
No newline at end of file
24uuid = { workspace = true }
Modified lib/auth/src/handlers/devices.rs View lib/auth/src/handlers/devices.rs
@@ -1,5 +1,5 @@
1use axum::Json;
12use axum::extract::{Path, Query, State};
2use axum::Json;
33use export::rest::{AppResponseData, NoData};
44use session::Session;
55use uuid::Uuid;
@@ -28,7 +28,10 @@
2828 Query(query): Query<ListQuery>,
2929) -> Result<AppResponseData<Vec<DeviceDto>>, ApiError> {
3030 let uid = require_uid(&session)?;
31 let devices = service.list_devices(uid, query).await.map_err(ApiError::from)?;
31 let devices = service
32 .list_devices(uid, query)
33 .await
34 .map_err(ApiError::from)?;
3235 Ok(AppResponseData::ok(devices))
3336}
3437
Modified lib/auth/src/handlers/mod.rs View lib/auth/src/handlers/mod.rs
@@ -11,6 +11,7 @@
1111pub mod delete_notification;
1212pub mod delete_ssh_key;
1313pub mod deletion_status;
14pub mod devices;
1415pub mod forgot_password;
1516pub mod get_preferences;
1617pub mod get_user;
@@ -22,10 +23,7 @@
2223pub mod login;
2324pub mod login_2fa;
2425pub mod logout;
25pub mod devices;
2626pub mod mark_notifications_read;
27pub mod rotate_access_key;
28pub mod update_access_key;
2927pub mod me;
3028pub mod pow_challenge;
3129pub mod reclaim;
@@ -37,13 +35,15 @@
3735pub mod resend_code;
3836pub mod reset_password;
3937pub mod reset_password_page;
38pub mod rotate_access_key;
4039pub mod totp_disable;
4140pub mod totp_enable;
4241pub mod totp_setup;
4342pub mod totp_status;
4443pub mod unread_notifications_count;
44pub mod update_access_key;
4545pub mod update_preferences;
4646pub mod update_profile;
47pub mod users;
4748pub mod verify_email;
48pub mod users;
4949pub mod verify_email_page;
Modified lib/auth/src/handlers/update_access_key.rs View lib/auth/src/handlers/update_access_key.rs
@@ -1,5 +1,5 @@
1use axum::Json;
12use axum::extract::{Path, State};
2use axum::Json;
33use export::rest::{AppResponseData, NoData};
44use session::Session;
55use uuid::Uuid;
Modified lib/auth/src/services/access_keys.rs View lib/auth/src/services/access_keys.rs
@@ -1,7 +1,8 @@
11use models::users::user_access_keys::AccessKeyScope;
22use models::users::{user, user_access_keys};
33use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set,
4 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
5 QuerySelect, Set,
56};
67use serde::{Deserialize, Serialize};
78use time::OffsetDateTime;
Modified lib/auth/src/services/devices.rs View lib/auth/src/services/devices.rs
@@ -99,7 +99,9 @@
9999 let now = OffsetDateTime::now_utc();
100100 TransactionTrait::transaction(db.primary(), |txn| {
101101 Box::pin(async move {
102 user_devices::Entity::delete_by_id(device_id).exec(txn).await?;
102 user_devices::Entity::delete_by_id(device_id)
103 .exec(txn)
104 .await?;
103105 user_sessions::Entity::update_many()
104106 .filter(user_sessions::Column::UserId.eq(uid))
105107 .filter(user_sessions::Column::DeviceId.eq(Some(device_id)))
@@ -114,4 +116,4 @@
114116 .await?;
115117 Ok(())
116118 }
117}
No newline at end of file
119}
Modified lib/auth/src/services/email.rs View lib/auth/src/services/email.rs
@@ -24,8 +24,6 @@
2424 }
2525}
2626
27
28
2927pub(crate) struct IssueCodeArtifacts {
3028 pub temp_id: Uuid,
3129 pub expires_in_secs: u64,
@@ -119,13 +117,8 @@
119117 let code = generate_numeric_code(config.verify_code_len);
120118 let temp_id = Uuid::now_v7();
121119 let code_key = format!("auth:code:{purpose_name}:{temp_id}");
122 export::redis::set_string(
123 self.redis(),
124 &code_key,
125 &code,
126 config.verify_code_ttl_secs,
127 )
128 .await?;
120 export::redis::set_string(self.redis(), &code_key, &code, config.verify_code_ttl_secs)
121 .await?;
129122
130123 let row = user_email_verification_codes::ActiveModel {
131124 id: Set(Uuid::now_v7()),
@@ -140,9 +133,9 @@
140133 row.insert(db.write()).await?;
141134
142135 Ok(IssueCodeArtifacts {
143 temp_id,
144 expires_in_secs: config.verify_code_ttl_secs,
145 code,
136 temp_id,
137 expires_in_secs: config.verify_code_ttl_secs,
138 code,
146139 })
147140 }
148141
Modified lib/auth/src/services/notifications.rs View lib/auth/src/services/notifications.rs
@@ -42,7 +42,8 @@
4242 query = query.offset(offset);
4343 }
4444 if let Some(notification_type) = notification_type {
45 query = query.filter(user_notifications::Column::NotificationType.eq(notification_type));
45 query =
46 query.filter(user_notifications::Column::NotificationType.eq(notification_type));
4647 }
4748 if unread == Some(true) {
4849 query = query.filter(user_notifications::Column::ReadAt.is_null());
@@ -88,7 +89,11 @@
8889 Ok(())
8990 }
9091
91 pub async fn mark_notifications_read(&self, uid: Uuid, ids: Vec<Uuid>) -> Result<u64, AuthError> {
92 pub async fn mark_notifications_read(
93 &self,
94 uid: Uuid,
95 ids: Vec<Uuid>,
96 ) -> Result<u64, AuthError> {
9297 let db = self.db();
9398 let result = user_notifications::Entity::update_many()
9499 .col_expr(
Modified lib/auth/src/services/profile.rs View lib/auth/src/services/profile.rs
@@ -1,6 +1,8 @@
11use models::users::user::ProfileVisibility;
22use models::users::{user, user_profiles};
3use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set, TransactionTrait};
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set, TransactionTrait,
5};
46use serde::{Deserialize, Serialize};
57use time::{Date, OffsetDateTime};
68use uuid::Uuid;
@@ -239,9 +241,7 @@
239241 .one(db.read())
240242 .await?;
241243 if exists.is_some() {
242 return Err(AuthError::Conflict(
243 "username already taken".to_string(),
244 ));
244 return Err(AuthError::Conflict("username already taken".to_string()));
245245 }
246246 let mut active = user.clone().into_active_model();
247247 active.username = Set(username.clone());
@@ -253,10 +253,7 @@
253253 user_id: uid,
254254 username: username.clone(),
255255 display_name: user.display_name.clone(),
256 avatar_url: space::services::spaces::resolve_user_avatar_url(
257 uid,
258 &user.avatar_url,
259 ),
256 avatar_url: space::services::spaces::resolve_user_avatar_url(uid, &user.avatar_url),
260257 })
261258 }
262259}
Modified lib/auth/src/services/ssh_keys.rs View lib/auth/src/services/ssh_keys.rs
@@ -1,7 +1,8 @@
11use models::users::user_ssh_keys;
22use models::users::user_ssh_keys::SshKeyType;
33use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set,
4 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
5 QuerySelect, Set,
56};
67use serde::{Deserialize, Serialize};
78use ssh_key::{Algorithm, EcdsaCurve, HashAlg};
@@ -110,15 +111,15 @@
110111 limit,
111112 offset,
112113 items: rows
113 .into_iter()
114 .map(|row| SshKeyDto {
115 id: row.id,
116 name: row.name,
117 key_type: row.key_type,
118 fingerprint: row.fingerprint,
119 created_at: row.created_at,
120 })
121 .collect(),
114 .into_iter()
115 .map(|row| SshKeyDto {
116 id: row.id,
117 name: row.name,
118 key_type: row.key_type,
119 fingerprint: row.fingerprint,
120 created_at: row.created_at,
121 })
122 .collect(),
122123 })
123124 }
124125
Modified lib/auth/src/services/two_factor.rs View lib/auth/src/services/two_factor.rs
@@ -49,9 +49,7 @@
4949}
5050
5151fn generate_recovery_codes() -> Vec<String> {
52 (0..RECOVERY_CODE_COUNT)
53 .map(|_| recovery_code())
54 .collect()
52 (0..RECOVERY_CODE_COUNT).map(|_| recovery_code()).collect()
5553}
5654
5755async fn insert_recovery_codes(
@@ -274,9 +272,7 @@
274272 let generated = generate_recovery_codes();
275273 let codes = generated.clone();
276274 TransactionTrait::transaction(db.primary(), |txn| {
277 Box::pin(async move {
278 insert_recovery_codes(txn, uid, &generated, now).await
279 })
275 Box::pin(async move { insert_recovery_codes(txn, uid, &generated, now).await })
280276 })
281277 .await?;
282278
Modified lib/auth/src/services/users.rs View lib/auth/src/services/users.rs
@@ -1,5 +1,5 @@
1use models::users::user;
12use models::users::user::ProfileVisibility;
2use models::users::user;
33use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
44use uuid::Uuid;
55
Modified lib/auth/src/openapi.rs View lib/auth/src/openapi.rs
@@ -14,10 +14,12 @@
1414use crate::handlers::pow_challenge::PowChallengeResp;
1515use crate::handlers::request_deletion::RequestDeletionCmd;
1616use crate::handlers::unread_notifications_count::UnreadCountResp;
17use crate::services::access_keys::{AccessKeyDto, CreateAccessKeyCmd, CreateAccessKeyResp, UpdateAccessKeyCmd};
18use crate::services::devices::{DeviceDto, UpdateDeviceCmd};
17use crate::services::access_keys::{
18 AccessKeyDto, CreateAccessKeyCmd, CreateAccessKeyResp, UpdateAccessKeyCmd,
19};
1920use crate::services::agreements::AcceptAgreementResp;
2021use crate::services::deletion::DeletionDto;
22use crate::services::devices::{DeviceDto, UpdateDeviceCmd};
2123use crate::services::email::{
2224 EmailChangeTicketDto, ReclaimCmd, ReclaimResp, RequestEmailChangeCmd, ResendCodeCmd,
2325 ResendCodeResp, VerifyEmailCmd, VerifyEmailResp,
Modified lib/auth/src/routes.rs View lib/auth/src/routes.rs
@@ -44,9 +44,9 @@
4444use crate::handlers::unread_notifications_count::unread_notifications_count;
4545use crate::handlers::update_preferences::update_preferences;
4646use crate::handlers::update_profile::update_profile;
47use crate::handlers::users;
4748use crate::handlers::verify_email::verify_email;
4849use crate::handlers::verify_email_page::verify_email_page;
49use crate::handlers::users;
5050use crate::services::AuthService;
5151use crate::share::guard::auth_guard;
5252
@@ -66,7 +66,10 @@
6666
6767 let authed = Router::new()
6868 .route("/users", get(users::list_users_batch))
69 .route("/users/by-username/{username}", get(users::get_user_by_username))
69 .route(
70 "/users/by-username/{username}",
71 get(users::get_user_by_username),
72 )
7073 .route("/users/{user_id}", get(get_user))
7174 .route("/auth/email/change", post(request_email_change))
7275 .route("/auth/email/change/confirm", post(confirm_email_change))
@@ -119,10 +122,7 @@
119122 "/me/notifications/read",
120123 put(crate::handlers::mark_notifications_read::mark_notifications_read),
121124 )
122 .route(
123 "/me/devices",
124 get(crate::handlers::devices::list_devices),
125 )
125 .route("/me/devices", get(crate::handlers::devices::list_devices))
126126 .route(
127127 "/me/devices/{device_id}",
128128 patch(crate::handlers::devices::update_device)
Modified lib/chat/src/handlers/bookmarks.rs View lib/chat/src/handlers/bookmarks.rs
@@ -28,7 +28,9 @@
2828) -> Result<AppResponseData<export::pagination::Page<BookmarkDto>>, ApiError> {
2929 let uid = require_uid(&session)?;
3030 let bookmarks = service.list_bookmarks(uid, room_id).await?;
31 Ok(AppResponseData::ok(export::pagination::paginate(bookmarks, &page)))
31 Ok(AppResponseData::ok(export::pagination::paginate(
32 bookmarks, &page,
33 )))
3234}
3335
3436#[utoipa::path(
Modified lib/chat/src/handlers/dms.rs View lib/chat/src/handlers/dms.rs
@@ -53,7 +53,9 @@
5353) -> Result<AppResponseData<export::pagination::Page<DmDto>>, ApiError> {
5454 let uid = require_uid(&session)?;
5555 let dms = service.list_dms(uid).await?;
56 Ok(AppResponseData::ok(export::pagination::paginate(dms, &page)))
56 Ok(AppResponseData::ok(export::pagination::paginate(
57 dms, &page,
58 )))
5759}
5860
5961#[utoipa::path(
Modified lib/chat/src/handlers/drafts.rs View lib/chat/src/handlers/drafts.rs
@@ -78,7 +78,9 @@
7878) -> Result<AppResponseData<export::pagination::Page<DraftDto>>, ApiError> {
7979 let uid = require_uid(&session)?;
8080 let drafts = service.list_my_drafts(uid).await?;
81 Ok(AppResponseData::ok(export::pagination::paginate(drafts, &page)))
81 Ok(AppResponseData::ok(export::pagination::paginate(
82 drafts, &page,
83 )))
8284}
8385
8486#[utoipa::path(
Modified lib/chat/src/handlers/room_admin.rs View lib/chat/src/handlers/room_admin.rs
@@ -5,9 +5,7 @@
55use uuid::Uuid;
66
77use crate::errors::ApiError;
8use crate::services::room_admin::{
9 BanRoomMemberCmd, RoomBanDto, UpdateRoomMemberCmd,
10};
8use crate::services::room_admin::{BanRoomMemberCmd, RoomBanDto, UpdateRoomMemberCmd};
119use crate::services::rooms::RoomMemberDto;
1210use crate::services::service::ChatService;
1311
@@ -54,7 +52,9 @@
5452 Json(cmd): Json<UpdateRoomMemberCmd>,
5553) -> Result<AppResponseData<RoomMemberDto>, ApiError> {
5654 let uid = require_uid(&session)?;
57 let member = service.update_room_member(uid, room_id, user_id, cmd).await?;
55 let member = service
56 .update_room_member(uid, room_id, user_id, cmd)
57 .await?;
5858 Ok(AppResponseData::ok(member))
5959}
6060
@@ -99,7 +99,9 @@
9999) -> Result<AppResponseData<export::pagination::Page<RoomBanDto>>, ApiError> {
100100 let uid = require_uid(&session)?;
101101 let bans = service.list_room_bans(uid, room_id).await?;
102 Ok(AppResponseData::ok(export::pagination::paginate(bans, &page)))
102 Ok(AppResponseData::ok(export::pagination::paginate(
103 bans, &page,
104 )))
103105}
104106
105107#[utoipa::path(
@@ -175,6 +177,8 @@
175177 Json(cmd): Json<TransferOwnerCmd>,
176178) -> Result<AppResponseData<NoData>, ApiError> {
177179 let uid = require_uid(&session)?;
178 service.transfer_room_owner(uid, room_id, cmd.user_id).await?;
180 service
181 .transfer_room_owner(uid, room_id, cmd.user_id)
182 .await?;
179183 Ok(AppResponseData::empty())
180184}
Modified lib/chat/src/handlers/threads.rs View lib/chat/src/handlers/threads.rs
@@ -81,7 +81,9 @@
8181) -> Result<AppResponseData<export::pagination::Page<ThreadDto>>, ApiError> {
8282 let uid = require_uid(&session)?;
8383 let threads = service.list_my_threads(uid).await?;
84 Ok(AppResponseData::ok(export::pagination::paginate(threads, &page)))
84 Ok(AppResponseData::ok(export::pagination::paginate(
85 threads, &page,
86 )))
8587}
8688
8789#[utoipa::path(
Modified lib/chat/src/handlers/webhooks.rs View lib/chat/src/handlers/webhooks.rs
@@ -7,7 +7,9 @@
77use crate::errors::ApiError;
88use crate::services::messages::MessageDto;
99use crate::services::service::ChatService;
10use crate::services::webhooks::{CreateWebhookCmd, UpdateWebhookCmd, WebhookDto, WebhookPostCmd, WebhookSecretDto};
10use crate::services::webhooks::{
11 CreateWebhookCmd, UpdateWebhookCmd, WebhookDto, WebhookPostCmd, WebhookSecretDto,
12};
1113
1214#[utoipa::path(
1315 post,
@@ -55,7 +57,9 @@
5557) -> Result<AppResponseData<export::pagination::Page<WebhookDto>>, ApiError> {
5658 let uid = require_uid(&session)?;
5759 let rows = service.list_webhooks(uid, room_id).await?;
58 Ok(AppResponseData::ok(export::pagination::paginate(rows, &page)))
60 Ok(AppResponseData::ok(export::pagination::paginate(
61 rows, &page,
62 )))
5963}
6064
6165#[utoipa::path(
@@ -127,7 +131,9 @@
127131 Json(cmd): Json<UpdateWebhookCmd>,
128132) -> Result<AppResponseData<WebhookDto>, ApiError> {
129133 let uid = require_uid(&session)?;
130 let dto = service.update_webhook(uid, room_id, webhook_id, cmd).await?;
134 let dto = service
135 .update_webhook(uid, room_id, webhook_id, cmd)
136 .await?;
131137 Ok(AppResponseData::ok(dto))
132138}
133139
Modified lib/chat/src/services/attachments.rs View lib/chat/src/services/attachments.rs
@@ -2,8 +2,8 @@
22use axum::http::header;
33use models::chat::attachments::{self, AttachmentStatus};
44use sea_orm::{
5 ActiveModelTrait, ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect,
6 Set,
5 ActiveModelTrait, ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter,
6 QuerySelect, Set,
77};
88use serde::{Deserialize, Serialize};
99use time::OffsetDateTime;
Modified lib/chat/src/services/dms.rs View lib/chat/src/services/dms.rs
@@ -220,12 +220,10 @@
220220 }
221221 };
222222
223 self.ensure_dm_members(room.id, caller, &member_uids).await?;
223 self.ensure_dm_members(room.id, caller, &member_uids)
224 .await?;
224225
225 let members = users
226 .into_iter()
227 .map(UserDto::from)
228 .collect::<Vec<_>>();
226 let members = users.into_iter().map(UserDto::from).collect::<Vec<_>>();
229227 self.build_dm_dto(caller, room, None, members).await
230228 }
231229
Modified lib/chat/src/services/messages.rs View lib/chat/src/services/messages.rs
@@ -821,10 +821,7 @@
821821 messages::Column::MentionEveryone,
822822 Expr::value(mention_everyone),
823823 )
824 .col_expr(
825 messages::Column::MentionGroupIds,
826 Expr::value(groups_json),
827 )
824 .col_expr(messages::Column::MentionGroupIds, Expr::value(groups_json))
828825 .col_expr(messages::Column::EditedAt, Expr::value(now))
829826 .exec(txn)
830827 .await?;
Modified lib/chat/src/services/outbound.rs View lib/chat/src/services/outbound.rs
@@ -105,17 +105,21 @@
105105 && !(v4.octets()[0] == 100 && (v4.octets()[1] & 0b1100_0000) == 0b0100_0000)
106106 }
107107 std::net::IpAddr::V6(v6) => {
108 !v6.is_loopback() && !v6.is_unspecified() && !v6.is_multicast() && {
109 let segments = v6.segments();
110 // 64:ff9b::/96 (NAT64), fc00::/7 (ULA), fe80::/10 (link-local),
111 // ::ffff:0:0/96 (IPv4-mapped) handled via to_ipv4 below.
112 !(segments[0] == 0x0064 && segments[1] == 0xff9b)
113 && (segments[0] & 0xfe00) != 0xfc00
114 && (segments[0] & 0xffc0) != 0xfe80
115 } && match v6.to_ipv4_mapped() {
116 Some(v4) => is_public_ip(&std::net::IpAddr::V4(v4)),
117 None => true,
118 }
108 !v6.is_loopback()
109 && !v6.is_unspecified()
110 && !v6.is_multicast()
111 && {
112 let segments = v6.segments();
113 // 64:ff9b::/96 (NAT64), fc00::/7 (ULA), fe80::/10 (link-local),
114 // ::ffff:0:0/96 (IPv4-mapped) handled via to_ipv4 below.
115 !(segments[0] == 0x0064 && segments[1] == 0xff9b)
116 && (segments[0] & 0xfe00) != 0xfc00
117 && (segments[0] & 0xffc0) != 0xfe80
118 }
119 && match v6.to_ipv4_mapped() {
120 Some(v4) => is_public_ip(&std::net::IpAddr::V4(v4)),
121 None => true,
122 }
119123 }
120124 }
121125}
@@ -295,8 +299,13 @@
295299 .one(self.db().read())
296300 .await?
297301 else {
298 self.finish_delivery(delivery.id, WebhookDeliveryStatus::Failed, Some("webhook deleted".to_string()), delivery.attempts)
299 .await;
302 self.finish_delivery(
303 delivery.id,
304 WebhookDeliveryStatus::Failed,
305 Some("webhook deleted".to_string()),
306 delivery.attempts,
307 )
308 .await;
300309 continue;
301310 };
302311 let result = if webhook.disabled {
@@ -321,8 +330,8 @@
321330 let mut am: webhook_deliveries::ActiveModel = delivery.clone().into();
322331 am.status = Set(WebhookDeliveryStatus::Pending);
323332 am.attempts = Set(attempts);
324 am.next_retry_at =
325 Set(OffsetDateTime::now_utc() + time::Duration::seconds(60 * attempts as i64));
333 am.next_retry_at = Set(OffsetDateTime::now_utc()
334 + time::Duration::seconds(60 * attempts as i64));
326335 am.last_error = Set(Some(error.clone()));
327336 am.updated_at = Set(OffsetDateTime::now_utc());
328337 let _ = am.update(self.db().write()).await;
Modified lib/chat/src/services/reminders.rs View lib/chat/src/services/reminders.rs
@@ -100,10 +100,8 @@
100100 .order_by_asc(message_reminders::Column::RemindAt)
101101 .all(self.db().read())
102102 .await?;
103 let mut items: Vec<ReminderDto> = rows
104 .iter()
105 .map(|r| ReminderDto::from(r.clone()))
106 .collect();
103 let mut items: Vec<ReminderDto> =
104 rows.iter().map(|r| ReminderDto::from(r.clone())).collect();
107105 let msg_ids: Vec<Uuid> = rows.iter().map(|r| r.message_id).collect();
108106 if !msg_ids.is_empty() {
109107 use models::chat::messages;
@@ -146,7 +144,9 @@
146144 if let Some(remind_at) = cmd.remind_at
147145 && remind_at <= OffsetDateTime::now_utc()
148146 {
149 return Err(ChatError::BadRequest("remind_at must be in the future".to_string()));
147 return Err(ChatError::BadRequest(
148 "remind_at must be in the future".to_string(),
149 ));
150150 }
151151 let mut am: message_reminders::ActiveModel = row.into();
152152 if let Some(remind_at) = cmd.remind_at {
Modified lib/chat/src/services/room_admin.rs View lib/chat/src/services/room_admin.rs
@@ -111,7 +111,10 @@
111111 return Err(ChatError::NotFound);
112112 };
113113 let user_map = self.load_user_briefs(&[row.user_id]).await?;
114 let user = user_map.get(&row.user_id).cloned().ok_or(ChatError::NotFound)?;
114 let user = user_map
115 .get(&row.user_id)
116 .cloned()
117 .ok_or(ChatError::NotFound)?;
115118 Ok(RoomMemberDto {
116119 user,
117120 role: row.role,
@@ -128,9 +131,12 @@
128131 ) -> Result<(), ChatError> {
129132 let room = lookup_room(self, room_id).await?;
130133 if caller == uid {
131 return Err(ChatError::BadRequest("use leave endpoint instead".to_string()));
134 return Err(ChatError::BadRequest(
135 "use leave endpoint instead".to_string(),
136 ));
132137 }
133 let caller_role = require_room_role(self, room_id, caller, RoomMemberRole::Moderator).await?;
138 let caller_role =
139 require_room_role(self, room_id, caller, RoomMemberRole::Moderator).await?;
134140 let target_role = active_room_role(self, room_id, uid)
135141 .await?
136142 .ok_or(ChatError::NotFound)?;
@@ -139,7 +145,10 @@
139145 }
140146 set_left_at(self, room_id, uid).await?;
141147 self.realtime
142 .publish_room(&RoomEvent::MemberLeft { room_id, user_id: uid })
148 .publish_room(&RoomEvent::MemberLeft {
149 room_id,
150 user_id: uid,
151 })
143152 .await;
144153 Ok(())
145154 }
@@ -180,7 +189,10 @@
180189 .await?;
181190 set_left_at(self, room_id, cmd.user_id).await?;
182191 self.realtime
183 .publish_room(&RoomEvent::MemberLeft { room_id, user_id: cmd.user_id })
192 .publish_room(&RoomEvent::MemberLeft {
193 room_id,
194 user_id: cmd.user_id,
195 })
184196 .await;
185197 self.build_room_ban_dto(row).await
186198 }
@@ -201,7 +213,11 @@
201213 Ok(())
202214 }
203215
204 pub async fn list_room_bans(&self, caller: Uuid, room_id: Uuid) -> Result<Vec<RoomBanDto>, ChatError> {
216 pub async fn list_room_bans(
217 &self,
218 caller: Uuid,
219 room_id: Uuid,
220 ) -> Result<Vec<RoomBanDto>, ChatError> {
205221 require_room_role(self, room_id, caller, RoomMemberRole::Moderator).await?;
206222 let rows = room_bans::Entity::find()
207223 .filter(room_bans::Column::RoomId.eq(room_id))
@@ -226,7 +242,9 @@
226242 return Err(ChatError::Forbidden);
227243 }
228244 if new_owner == caller {
229 return Err(ChatError::BadRequest("cannot transfer ownership to yourself".to_string()));
245 return Err(ChatError::BadRequest(
246 "cannot transfer ownership to yourself".to_string(),
247 ));
230248 }
231249 let target = room_members::Entity::find_by_id((room_id, new_owner))
232250 .one(self.db().read())
Modified lib/chat/src/services/rooms.rs View lib/chat/src/services/rooms.rs
@@ -1,8 +1,8 @@
11use models::chat::messages::{self, ChatMessageType};
2use models::dir::dirs::{self, DirKind};
32use models::chat::read_states;
43use models::chat::room_seq;
54use models::chat::room_user_settings;
5use models::dir::dirs::{self, DirKind};
66use models::room::room_invites;
77use models::room::room_members::{self, RoomMemberRole};
88use models::room::rooms::{self, RoomKind};
@@ -14,8 +14,8 @@
1414 Set, TransactionTrait,
1515};
1616use serde::{Deserialize, Serialize};
17use space::services::spaces::UserDto;
1718use space::share::authz::{MANAGE_INVITES, effective_permissions};
18use space::services::spaces::UserDto;
1919use time::OffsetDateTime;
2020use utoipa::{IntoParams, ToSchema};
2121use uuid::Uuid;
@@ -224,8 +224,12 @@
224224 .await?;
225225 let count_map: std::collections::HashMap<Uuid, i64> = member_counts.into_iter().collect();
226226
227 let owner_ids: Vec<Uuid> =
228 rooms.iter().map(|r| r.owner_id).collect::<Vec<_>>().into_iter().collect();
227 let owner_ids: Vec<Uuid> = rooms
228 .iter()
229 .map(|r| r.owner_id)
230 .collect::<Vec<_>>()
231 .into_iter()
232 .collect();
229233 let owner_map = self.load_user_briefs(&owner_ids).await?;
230234 let mut result = Vec::new();
231235 for room in rooms {
@@ -244,9 +248,7 @@
244248 .unwrap_or(0);
245249 let setting = settings_map.get(&room.id);
246250 if member.hide_muted_channels
247 && setting.is_some_and(|s| {
248 crate::share::notify::mute_active(s.muted, s.mute_until)
249 })
251 && setting.is_some_and(|s| crate::share::notify::mute_active(s.muted, s.mute_until))
250252 {
251253 continue;
252254 }
@@ -403,7 +405,11 @@
403405 let next = match parent {
404406 None => None,
405407 Some(dir_id) => {
406 let space_id = ctx.space.as_ref().map(|space| space.id).ok_or(ChatError::Forbidden)?;
408 let space_id = ctx
409 .space
410 .as_ref()
411 .map(|space| space.id)
412 .ok_or(ChatError::Forbidden)?;
407413 let node = dirs::Entity::find_by_id(dir_id)
408414 .filter(dirs::Column::SpaceId.eq(space_id))
409415 .one(self.db().read())
@@ -818,9 +824,7 @@
818824 inviter_id: row.inviter_id,
819825 inviter: user_map.get(&row.inviter_id).cloned(),
820826 invitee_id: row.invitee_user_id,
821 invitee: row
822 .invitee_user_id
823 .and_then(|i| user_map.get(&i).cloned()),
827 invitee: row.invitee_user_id.and_then(|i| user_map.get(&i).cloned()),
824828 accept_code,
825829 created_at: row.created_at,
826830 expires_at: row.expires_at,
@@ -888,13 +892,16 @@
888892 revoked_at: r.revoked_at,
889893 })
890894 .collect();
891 let mut uids: Vec<Uuid> = items.iter().flat_map(|i| {
892 let mut v = vec![i.inviter_id];
893 if let Some(id) = i.invitee_id {
894 v.push(id);
895 }
896 v
897 }).collect();
895 let mut uids: Vec<Uuid> = items
896 .iter()
897 .flat_map(|i| {
898 let mut v = vec![i.inviter_id];
899 if let Some(id) = i.invitee_id {
900 v.push(id);
901 }
902 v
903 })
904 .collect();
898905 uids.sort();
899906 uids.dedup();
900907 if !uids.is_empty() {
Modified lib/chat/src/services/threads.rs View lib/chat/src/services/threads.rs
@@ -271,7 +271,12 @@
271271 .all(self.db().read())
272272 .await?;
273273 let items = self.build_thread_dto(rows).await?;
274 Ok(ThreadPage { items, total, limit, offset })
274 Ok(ThreadPage {
275 items,
276 total,
277 limit,
278 offset,
279 })
275280 }
276281
277282 pub async fn list_my_threads(&self, caller: Uuid) -> Result<Vec<ThreadDto>, ChatError> {
Modified lib/chat/src/services/unfurl.rs View lib/chat/src/services/unfurl.rs
@@ -29,12 +29,17 @@
2929 let mut urls = Vec::new();
3030 for token in content.split_whitespace() {
3131 let trimmed = token.trim_matches(|c: char| {
32 !c.is_alphanumeric() && !matches!(c, ':' | '/' | '.' | '-' | '_' | '?' | '&' | '=' | '%' | '~' | '+' | '#' | '@')
32 !c.is_alphanumeric()
33 && !matches!(
34 c,
35 ':' | '/' | '.' | '-' | '_' | '?' | '&' | '=' | '%' | '~' | '+' | '#' | '@'
36 )
3337 });
3438 if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
3539 continue;
3640 }
37 let cleaned = trimmed.trim_end_matches(|c: char| matches!(c, '.' | ',' | ')' | ']' | '>' | '"' | '\''));
41 let cleaned = trimmed
42 .trim_end_matches(|c: char| matches!(c, '.' | ',' | ')' | ']' | '>' | '"' | '\''));
3843 if cleaned.len() < 12 || cleaned.len() > 2048 {
3944 continue;
4045 }
@@ -166,7 +171,8 @@
166171
167172 Ok(FetchedMeta {
168173 title: meta_content(head, "og:title").or_else(|| page_title(head)),
169 description: meta_content(head, "og:description").or_else(|| meta_content(head, "description")),
174 description: meta_content(head, "og:description")
175 .or_else(|| meta_content(head, "description")),
170176 image_url: meta_content(head, "og:image"),
171177 site_name: meta_content(head, "og:site_name"),
172178 })
@@ -236,11 +242,22 @@
236242 &self,
237243 hash: &str,
238244 url: &str,
239 meta: Option<(Option<String>, Option<String>, Option<String>, Option<String>)>,
245 meta: Option<(
246 Option<String>,
247 Option<String>,
248 Option<String>,
249 Option<String>,
250 )>,
240251 now: OffsetDateTime,
241252 ) -> Option<link_unfurls::Model> {
242253 let (status, title, description, image_url, site_name) = match &meta {
243 Some((t, d, i, s)) => (UnfurlStatus::Ready, t.clone(), d.clone(), i.clone(), s.clone()),
254 Some((t, d, i, s)) => (
255 UnfurlStatus::Ready,
256 t.clone(),
257 d.clone(),
258 i.clone(),
259 s.clone(),
260 ),
244261 None => (UnfurlStatus::Failed, None, None, None, None),
245262 };
246263 let row = link_unfurls::ActiveModel {
Modified lib/chat/src/services/webhooks.rs View lib/chat/src/services/webhooks.rs
@@ -383,8 +383,9 @@
383383 .insert(txn)
384384 .await?;
385385 if let Some(tid) = thread_id {
386 if let Some(t) =
387 models::chat::threads::Entity::find_by_id(tid).one(txn).await?
386 if let Some(t) = models::chat::threads::Entity::find_by_id(tid)
387 .one(txn)
388 .await?
388389 {
389390 crate::services::threads::maintain_thread_on_reply(
390391 txn, &t, message_id, owner, now,
Modified lib/chat/src/routes.rs View lib/chat/src/routes.rs
@@ -72,14 +72,18 @@
7272 )
7373 .route(
7474 "/chat/rooms/{room_id}/bans",
75 get(crate::handlers::room_admin::list_room_bans).post(crate::handlers::room_admin::ban_room_member),
75 get(crate::handlers::room_admin::list_room_bans)
76 .post(crate::handlers::room_admin::ban_room_member),
7677 )
7778 .route(
7879 "/chat/rooms/{room_id}/bans/{user_id}",
7980 delete(crate::handlers::room_admin::unban_room_member),
8081 )
81 .route("/chat/rooms/{room_id}/owner", put(crate::handlers::room_admin::transfer_room_owner))
8282 .route(
83 "/chat/rooms/{room_id}/owner",
84 put(crate::handlers::room_admin::transfer_room_owner),
85 )
86 .route(
8387 "/chat/rooms/{room_id}/invites",
8488 post(create_room_invite).get(crate::handlers::rooms::list_room_invites),
8589 )
@@ -149,7 +153,8 @@
149153 .route("/chat/reminders", get(list_reminders))
150154 .route(
151155 "/chat/reminders/{reminder_id}",
152 axum::routing::delete(cancel_reminder).patch(crate::handlers::reminders::update_reminder),
156 axum::routing::delete(cancel_reminder)
157 .patch(crate::handlers::reminders::update_reminder),
153158 )
154159 .route(
155160 "/chat/rooms/{room_id}/read-state",
@@ -193,7 +198,9 @@
193198 )
194199 .route(
195200 "/chat/rooms/{room_id}/draft",
196 get(get_draft).put(upsert_draft).delete(crate::handlers::drafts::delete_draft),
201 get(get_draft)
202 .put(upsert_draft)
203 .delete(crate::handlers::drafts::delete_draft),
197204 )
198205 .route("/chat/drafts", get(list_my_drafts))
199206 .route("/chat/presence", axum::routing::put(set_presence))
@@ -233,8 +240,7 @@
233240 chat_guard,
234241 ));
235242
236 let open =
237 Router::new().route("/chat/hooks/{token}", post(post_via_webhook));
243 let open = Router::new().route("/chat/hooks/{token}", post(post_via_webhook));
238244
239245 guarded.merge(open).merge(space_room_routes)
240246}
Added lib/ci/src/handlers/artifacts.rs View lib/ci/src/handlers/artifacts.rs
@@ -0,0 +1,62 @@
1use axum::extract::{Path, State};
2use axum::response::Redirect;
3use export::rest::AppResponseData;
4use session::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}
Added lib/ci/src/handlers/logs.rs View lib/ci/src/handlers/logs.rs
@@ -0,0 +1,45 @@
1use axum::extract::{Path, Query, State};
2use export::rest::AppResponseData;
3use session::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}
Added lib/ci/src/handlers/mod.rs View lib/ci/src/handlers/mod.rs
@@ -0,0 +1,5 @@
1pub mod artifacts;
2pub mod logs;
3pub mod runner_protocol;
4pub mod runs;
5pub mod space;
Added lib/ci/src/handlers/runner_protocol.rs View lib/ci/src/handlers/runner_protocol.rs
@@ -0,0 +1,257 @@
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 ClaimResponse, HeartbeatRequest, HeartbeatResponse, JobFinishRequest, JobFinishState,
9 JobPayload, LogChunksRequest, RegisterRequest, RegisterResponse, StepReport, StepReportState,
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 response = service
29 .register_runner(
30 &request.token,
31 &request.name,
32 request.labels,
33 request.capacity,
34 request.version,
35 )
36 .await?;
37 Ok(AppResponseData::ok(response))
38}
39
40#[utoipa::path(
41 post,
42 path = "/runner/heartbeat",
43 tag = "runner",
44 description = "runner 心跳,同时下发需要终止的作业 id。",
45 request_body = HeartbeatRequest,
46 responses((status = 200, description = "heartbeat accepted", body = AppResponseData<HeartbeatResponse>))
47)]
48pub async fn heartbeat(
49 State(service): State<CiService>,
50 auth: RunnerAuth,
51 Json(_request): Json<HeartbeatRequest>,
52) -> Result<AppResponseData<HeartbeatResponse>, ApiError> {
53 let cancel = service.runner_heartbeat(auth.runner_id).await?;
54 Ok(AppResponseData::ok(HeartbeatResponse {
55 cancel,
56 next_heartbeat_secs: service.config().ci.heartbeat_interval_secs,
57 }))
58}
59
60#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
61pub struct ClaimEnvelope {
62 #[serde(default)]
63 pub job: Option<JobPayload>,
64}
65
66#[utoipa::path(
67 post,
68 path = "/runner/jobs/claim",
69 tag = "runner",
70 description = "长轮询领取一个匹配标签的作业。",
71 request_body = ClaimRequest,
72 responses((status = 200, description = "claim result", body = AppResponseData<ClaimEnvelope>))
73)]
74pub async fn claim(
75 State(service): State<CiService>,
76 auth: RunnerAuth,
77 Json(request): Json<ClaimRequest>,
78) -> Result<AppResponseData<ClaimEnvelope>, ApiError> {
79 let wait = request
80 .wait_secs
81 .unwrap_or_else(|| service.config().ci.long_poll_timeout_secs)
82 .min(service.config().ci.long_poll_timeout_secs.max(1) * 2);
83 let labels = service.runner_labels(auth.runner_id).await?;
84 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(wait.max(1));
85 loop {
86 if let Some(job_id) = service.claim_job(auth.runner_id, &labels).await? {
87 if let Some(payload) = service.claim_payload(job_id, auth.runner_id).await? {
88 return Ok(AppResponseData::ok(ClaimEnvelope { job: Some(payload) }));
89 }
90 }
91 if std::time::Instant::now() >= deadline {
92 return Ok(AppResponseData::ok(ClaimEnvelope { job: None }));
93 }
94 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
95 }
96}
97
98#[utoipa::path(
99 post,
100 path = "/runner/jobs/{job_id}/start",
101 tag = "runner",
102 description = "runner 声明开始执行作业。",
103 params(("job_id" = Uuid, Path)),
104 responses((status = 200, description = "started", body = AppResponseData<Uuid>))
105)]
106pub async fn start(
107 State(service): State<CiService>,
108 auth: RunnerAuth,
109 Path(job_id): Path<Uuid>,
110) -> Result<AppResponseData<Uuid>, ApiError> {
111 let run_id = service.start_job(job_id, auth.runner_id).await?;
112 Ok(AppResponseData::ok(run_id))
113}
114
115#[utoipa::path(
116 post,
117 path = "/runner/jobs/{job_id}/steps/{index}",
118 tag = "runner",
119 description = "上报某个步骤的开始或结束状态。",
120 params(("job_id" = Uuid, Path), ("index" = i32, Path)),
121 request_body = StepReport,
122 responses((status = 200, description = "step recorded", body = AppResponseData<Uuid>))
123)]
124pub async fn report_step(
125 State(service): State<CiService>,
126 auth: RunnerAuth,
127 Path((job_id, index)): Path<(Uuid, i32)>,
128 Json(report): Json<StepReport>,
129) -> Result<AppResponseData<Uuid>, ApiError> {
130 service
131 .report_step(job_id, auth.runner_id, index, &report)
132 .await?;
133 Ok(AppResponseData::ok(job_id))
134}
135
136#[utoipa::path(
137 post,
138 path = "/runner/jobs/{job_id}/logs",
139 tag = "runner",
140 description = "批量上报日志块。",
141 params(("job_id" = Uuid, Path)),
142 request_body = LogChunksRequest,
143 responses((status = 200, description = "logs stored", body = AppResponseData<Uuid>))
144)]
145pub async fn upload_logs(
146 State(service): State<CiService>,
147 auth: RunnerAuth,
148 Path(job_id): Path<Uuid>,
149 Json(request): Json<LogChunksRequest>,
150) -> Result<AppResponseData<Uuid>, ApiError> {
151 service
152 .append_log_chunks(job_id, auth.runner_id, &request.chunks)
153 .await?;
154 Ok(AppResponseData::ok(job_id))
155}
156
157#[utoipa::path(
158 post,
159 path = "/runner/jobs/{job_id}/finish",
160 tag = "runner",
161 description = "上报作业终态(幂等)。",
162 params(("job_id" = Uuid, Path)),
163 request_body = JobFinishRequest,
164 responses((status = 200, description = "job finished", body = AppResponseData<Uuid>))
165)]
166pub async fn finish(
167 State(service): State<CiService>,
168 auth: RunnerAuth,
169 Path(job_id): Path<Uuid>,
170 Json(request): Json<JobFinishRequest>,
171) -> Result<AppResponseData<Uuid>, ApiError> {
172 let run_id = service.finish_job(job_id, auth.runner_id, &request).await?;
173 service.advance_jobs().await?;
174 service.finalize_runs().await?;
175 Ok(AppResponseData::ok(run_id))
176}
177
178#[utoipa::path(
179 post,
180 path = "/runner/jobs/{job_id}/renew",
181 tag = "runner",
182 description = "续租当前作业。",
183 params(("job_id" = Uuid, Path)),
184 responses((status = 200, description = "lease renewed", body = AppResponseData<Uuid>))
185)]
186pub async fn renew(
187 State(service): State<CiService>,
188 auth: RunnerAuth,
189 Path(job_id): Path<Uuid>,
190) -> Result<AppResponseData<Uuid>, ApiError> {
191 service.renew_lease(job_id, auth.runner_id).await?;
192 Ok(AppResponseData::ok(job_id))
193}
194
195#[utoipa::path(
196 post,
197 path = "/runner/jobs/{job_id}/artifacts/init",
198 tag = "runner",
199 description = "申请产物上传地址。",
200 params(("job_id" = Uuid, Path)),
201 request_body = ArtifactInitRequest,
202 responses((status = 200, description = "artifact initialized", body = AppResponseData<ArtifactInitResponse>))
203)]
204pub async fn init_artifact(
205 State(service): State<CiService>,
206 auth: RunnerAuth,
207 Path(job_id): Path<Uuid>,
208 Json(request): Json<ArtifactInitRequest>,
209) -> Result<AppResponseData<ArtifactInitResponse>, ApiError> {
210 let (artifact_id, upload_url) = service
211 .init_artifact(
212 job_id,
213 auth.runner_id,
214 &request.name,
215 request.size,
216 request.sha256,
217 )
218 .await?;
219 Ok(AppResponseData::ok(ArtifactInitResponse {
220 artifact_id,
221 upload_url,
222 }))
223}
224
225#[utoipa::path(
226 post,
227 path = "/runner/jobs/{job_id}/artifacts/{artifact_id}/complete",
228 tag = "runner",
229 description = "确认产物已上传。",
230 params(("job_id" = Uuid, Path), ("artifact_id" = Uuid, Path)),
231 request_body = ArtifactCompleteRequest,
232 responses((status = 200, description = "artifact completed", body = AppResponseData<Uuid>))
233)]
234pub async fn complete_artifact(
235 State(service): State<CiService>,
236 auth: RunnerAuth,
237 Path((job_id, artifact_id)): Path<(Uuid, Uuid)>,
238 Json(request): Json<ArtifactCompleteRequest>,
239) -> Result<AppResponseData<Uuid>, ApiError> {
240 service
241 .complete_artifact(
242 job_id,
243 auth.runner_id,
244 artifact_id,
245 request.size,
246 request.sha256,
247 )
248 .await?;
249 Ok(AppResponseData::ok(artifact_id))
250}
251
252#[allow(dead_code)]
253fn _assert_types() {
254 let _ = StepReportState::Started;
255 let _ = JobFinishState::Success;
256 let _: Option<ClaimResponse> = None;
257}
Added lib/ci/src/handlers/runs.rs View lib/ci/src/handlers/runs.rs
@@ -0,0 +1,171 @@
1use axum::Json;
2use axum::extract::{Path, Query, State};
3use export::rest::AppResponseData;
4use session::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 = "读取指定修订版本下 `.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}
Added lib/ci/src/handlers/space.rs View lib/ci/src/handlers/space.rs
@@ -0,0 +1,184 @@
1use std::collections::HashMap;
2
3use axum::Json;
4use axum::extract::{Path, State};
5use export::rest::AppResponseData;
6use session::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}
Added lib/ci/src/share/access.rs View lib/ci/src/share/access.rs
@@ -0,0 +1,103 @@
1use git::share::access::authorize_repo_access;
2use git::share::errors::GitError;
3use models::repo::repos;
4use models::space::spaces;
5use session::Session;
6use session::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<Uuid>> {
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<Uuid> {
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<Uuid>> {
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: Uuid) -> 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}
Added lib/ci/src/share/conditions.rs View lib/ci/src/share/conditions.rs
@@ -0,0 +1,129 @@
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}
Added lib/ci/src/share/mod.rs View lib/ci/src/share/mod.rs
@@ -0,0 +1,3 @@
1pub mod access;
2pub mod conditions;
3pub mod states;
Added lib/ci/src/share/states.rs View lib/ci/src/share/states.rs
@@ -0,0 +1,12 @@
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}
Added lib/ci/src/environments.rs View lib/ci/src/environments.rs
@@ -0,0 +1,462 @@
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<Uuid>,
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<Uuid>,
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<Uuid>,
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<Uuid>,
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<Uuid>,
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 = ci_environment::Entity::find()
294 .filter(ci_environment::Column::SpaceId.eq(space_id))
295 .filter(ci_environment::Column::Name.eq(environment.to_string()))
296 .one(self.db_read())
297 .await?;
298 if let Some(env_row) = env_row {
299 let env_secrets = ci_secret::Entity::find()
300 .filter(ci_secret::Column::EnvironmentId.eq(env_row.id))
301 .filter(ci_secret::Column::Scope.eq(CiSecretScope::Environment))
302 .all(self.db_read())
303 .await?;
304 for row in env_secrets {
305 let value = decrypt(self.encryption_key(), &row.value_ciphertext)?;
306 merged.insert(row.name, value);
307 }
308 }
309 }
310 Ok(merged.into_iter().collect())
311 }
312}
313
314enum SecretScopeTarget {
315 Repo(Uuid),
316 Space(Uuid),
317 Environment(Uuid),
318}
319
320fn validate_secret_name(name: &str) -> CiResult<String> {
321 let trimmed = name.trim();
322 if trimmed.is_empty() || trimmed.len() > 128 {
323 return Err(CiError::InvalidArgument(
324 "secret name must be 1-128 characters".to_string(),
325 ));
326 }
327 let valid = trimmed
328 .chars()
329 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
330 if !valid {
331 return Err(CiError::InvalidArgument(
332 "secret name may only contain alphanumerics, `_` and `-`".to_string(),
333 ));
334 }
335 Ok(trimmed.to_string())
336}
337
338impl CiService {
339 pub async fn list_environments(&self, space: &spaces::Model) -> CiResult<Vec<EnvironmentDto>> {
340 let rows = ci_environment::Entity::find()
341 .filter(ci_environment::Column::SpaceId.eq(space.id))
342 .order_by_asc(ci_environment::Column::Name)
343 .all(self.db_read())
344 .await?;
345 Ok(rows.into_iter().map(EnvironmentDto::from).collect())
346 }
347
348 pub async fn upsert_environment(
349 &self,
350 space_id: Uuid,
351 name: &str,
352 description: Option<String>,
353 required_labels: Vec<String>,
354 ) -> CiResult<EnvironmentDto> {
355 let name = name.trim();
356 if name.is_empty() || name.len() > 128 || name.contains('/') || name.contains('\\') {
357 return Err(CiError::InvalidArgument(
358 "environment name must be 1-128 characters without slashes".to_string(),
359 ));
360 }
361 if required_labels.iter().any(|l| l.trim().is_empty()) {
362 return Err(CiError::InvalidArgument(
363 "environment labels must not be empty".to_string(),
364 ));
365 }
366 let now = OffsetDateTime::now_utc();
367 let existing = ci_environment::Entity::find()
368 .filter(ci_environment::Column::SpaceId.eq(space_id))
369 .filter(ci_environment::Column::Name.eq(name.to_string()))
370 .one(self.db_read())
371 .await?;
372 let model = match existing {
373 Some(row) => {
374 let mut am: ci_environment::ActiveModel = row.into();
375 am.description = Set(description);
376 am.required_labels = Set(serde_json::to_value(&required_labels)?);
377 am.updated_at = Set(now);
378 am.update(self.db_write()).await?
379 }
380 None => {
381 ci_environment::ActiveModel {
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}
Added lib/ci/src/errors.rs View lib/ci/src/errors.rs
@@ -0,0 +1,163 @@
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] AppResponseData<NoData>),
86 #[response(status = 401, description = "authentication required")]
87 Unauthorized(#[to_schema] AppResponseData<NoData>),
88 #[response(status = 403, description = "operation not allowed")]
89 Forbidden(#[to_schema] AppResponseData<NoData>),
90 #[response(status = 404, description = "resource not found")]
91 NotFound(#[to_schema] AppResponseData<NoData>),
92 #[response(status = 409, description = "resource conflict")]
93 Conflict(#[to_schema] AppResponseData<NoData>),
94 #[response(status = 500, description = "internal server error")]
95 Internal(#[to_schema] AppResponseData<NoData>),
96}
97
98impl ApiError {
99 pub fn bad_request(message: impl Into<String>) -> Self {
100 Self::BadRequest(AppResponseData::err(400, message))
101 }
102
103 pub fn unauthorized() -> Self {
104 Self::Unauthorized(AppResponseData::err(401, "unauthorized"))
105 }
106
107 pub fn forbidden() -> Self {
108 Self::Forbidden(AppResponseData::err(403, "forbidden"))
109 }
110
111 pub fn not_found() -> Self {
112 Self::NotFound(AppResponseData::err(404, "not found"))
113 }
114
115 pub fn conflict(message: impl Into<String>) -> Self {
116 Self::Conflict(AppResponseData::err(409, message))
117 }
118
119 pub fn internal() -> Self {
120 Self::Internal(AppResponseData::err(500, "internal server error"))
121 }
122
123 fn decompose(self) -> (u16, AppResponseData<NoData>) {
124 match self {
125 Self::BadRequest(envelope) => (400, envelope),
126 Self::Unauthorized(envelope) => (401, envelope),
127 Self::Forbidden(envelope) => (403, envelope),
128 Self::NotFound(envelope) => (404, envelope),
129 Self::Conflict(envelope) => (409, envelope),
130 Self::Internal(envelope) => (500, envelope),
131 }
132 }
133}
134
135impl From<CiError> for ApiError {
136 fn from(error: CiError) -> Self {
137 Self::from(CiApiError::from(error))
138 }
139}
140
141impl From<CiApiError> for ApiError {
142 fn from(error: CiApiError) -> Self {
143 match error {
144 CiApiError::BadRequest(message) => Self::bad_request(message),
145 CiApiError::Unauthorized => Self::unauthorized(),
146 CiApiError::Forbidden => Self::forbidden(),
147 CiApiError::NotFound => Self::not_found(),
148 CiApiError::Conflict(message) => Self::conflict(message),
149 CiApiError::Internal(message) => {
150 tracing::warn!(message, "ci internal error");
151 Self::internal()
152 }
153 }
154 }
155}
156
157impl IntoResponse for ApiError {
158 fn into_response(self) -> Response {
159 let (code, envelope) = self.decompose();
160 let status = StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
161 (status, Json(envelope)).into_response()
162 }
163}
Added lib/ci/src/events.rs View lib/ci/src/events.rs
@@ -0,0 +1,35 @@
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}
Added lib/ci/src/lib.rs View lib/ci/src/lib.rs
@@ -0,0 +1,24 @@
1pub mod environments;
2pub mod errors;
3pub mod events;
4pub mod logs;
5pub mod net;
6pub mod outbound;
7pub mod runners;
8pub mod runs;
9pub mod scheduler;
10pub mod secrets;
11pub mod service;
12pub mod share;
13pub mod storage;
14pub mod triggers;
15pub mod workflows;
16
17pub use errors::{CiApiError, CiError};
18pub use service::CiService;
19pub use workflows::LoadedWorkflow;
20
21pub mod handlers;
22
23pub mod openapi;
24pub mod routes;
Added lib/ci/src/logs.rs View lib/ci/src/logs.rs
@@ -0,0 +1,128 @@
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) as u64;
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}
Added lib/ci/src/net.rs View lib/ci/src/net.rs
@@ -0,0 +1,25 @@
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}
Added lib/ci/src/openapi.rs View lib/ci/src/openapi.rs
@@ -0,0 +1,64 @@
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::runs::list_runs,
10 crate::handlers::runs::get_run,
11 crate::handlers::runs::cancel_run,
12 crate::handlers::runs::rerun,
13 crate::handlers::runs::trigger,
14 crate::handlers::runs::list_workflows,
15 crate::handlers::logs::read_logs,
16 crate::handlers::artifacts::list_artifacts,
17 crate::handlers::artifacts::download_artifact,
18 crate::handlers::space::list_repo_secrets,
19 crate::handlers::space::upsert_repo_secret,
20 crate::handlers::space::delete_repo_secret,
21 crate::handlers::space::list_space_secrets,
22 crate::handlers::space::list_environments,
23 crate::handlers::space::list_deployments,
24 ),
25 components(schemas(
26 models::ci::workflow_run::WorkflowRunState,
27 models::ci::workflow_run::WorkflowEventKind,
28 models::ci::workflow_job::WorkflowJobState,
29 models::ci::workflow_step::WorkflowStepState,
30 models::ci::workflow_artifact::ArtifactState,
31 models::ci::ci_secret::CiSecretScope,
32 models::ci::ci_deployment::DeploymentState,
33 ci_protocol::dsl::PullRequestAction,
34 AppResponseData<crate::triggers::RunPageDto>,
35 AppResponseData<crate::triggers::RunDetailDto>,
36 AppResponseData<crate::triggers::ArtifactDto>,
37 AppResponseData<crate::logs::LogPageDto>,
38 AppResponseData<crate::handlers::artifacts::ArtifactListDto>,
39 AppResponseData<crate::handlers::space::SecretListDto>,
40 AppResponseData<crate::handlers::space::SecretMutationDto>,
41 AppResponseData<crate::handlers::space::EnvironmentListDto>,
42 AppResponseData<crate::handlers::space::DeploymentListDto>,
43 AppResponseData<crate::handlers::space::WorkflowSummaryList>,
44 NoData,
45 crate::triggers::RunDto,
46 crate::triggers::RunDetailDto,
47 crate::triggers::RunPageDto,
48 crate::triggers::JobDto,
49 crate::triggers::StepDto,
50 crate::triggers::ArtifactDto,
51 crate::triggers::TriggerRunCmd,
52 crate::logs::LogPageDto,
53 crate::logs::LogChunkDto,
54 crate::handlers::artifacts::ArtifactListDto,
55 crate::handlers::space::SecretListDto,
56 crate::handlers::space::SecretMutationDto,
57 crate::handlers::space::EnvironmentListDto,
58 crate::handlers::space::DeploymentListDto,
59 crate::handlers::space::WorkflowSummaryList,
60 crate::handlers::space::UpsertSecretCmd,
61 )),
62 tags((name = "pipelines", description = "CI/CD 流水线、secrets 与部署环境"))
63)]
64pub struct PipelinesApiDoc;
Added lib/ci/src/outbound.rs View lib/ci/src/outbound.rs
@@ -0,0 +1,254 @@
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 pub async fn deliver_pending_repo_webhooks(&self) -> CiResult<u64> {
62 let now = OffsetDateTime::now_utc();
63 let stuck = now - time::Duration::minutes(CLAIM_STUCK_MINUTES);
64 repo_webhook_delivery::Entity::update_many()
65 .col_expr(
66 repo_webhook_delivery::Column::Status,
67 Expr::value(RepoWebhookDeliveryStatus::Pending),
68 )
69 .col_expr(repo_webhook_delivery::Column::UpdatedAt, Expr::value(now))
70 .filter(repo_webhook_delivery::Column::Status.eq(RepoWebhookDeliveryStatus::Delivering))
71 .filter(repo_webhook_delivery::Column::UpdatedAt.lt(stuck))
72 .exec(self.db_write())
73 .await?;
74
75 let due = repo_webhook_delivery::Entity::find()
76 .filter(repo_webhook_delivery::Column::Status.eq(RepoWebhookDeliveryStatus::Pending))
77 .filter(repo_webhook_delivery::Column::NextRetryAt.lte(now))
78 .order_by_asc(repo_webhook_delivery::Column::NextRetryAt)
79 .limit(50)
80 .all(self.db_read())
81 .await?;
82 let count = due.len() as u64;
83 let max_attempts = self.config().ci.outbound_max_attempts;
84 let backoff_base = self.config().ci.outbound_backoff_base_secs;
85 for delivery in due {
86 let claimed = repo_webhook_delivery::Entity::update_many()
87 .col_expr(
88 repo_webhook_delivery::Column::Status,
89 Expr::value(RepoWebhookDeliveryStatus::Delivering),
90 )
91 .col_expr(repo_webhook_delivery::Column::UpdatedAt, Expr::value(now))
92 .filter(repo_webhook_delivery::Column::Id.eq(delivery.id))
93 .filter(
94 repo_webhook_delivery::Column::Status.eq(RepoWebhookDeliveryStatus::Pending),
95 )
96 .exec(self.db_write())
97 .await?;
98 if claimed.rows_affected == 0 {
99 continue;
100 }
101 let hook = repo_webhooks::Entity::find_by_id(delivery.webhook_id)
102 .one(self.db_read())
103 .await?;
104 let Some(hook) = hook else {
105 self.finish_repo_delivery(
106 delivery.id,
107 RepoWebhookDeliveryStatus::Failed,
108 delivery.attempts,
109 None,
110 None,
111 Some("webhook deleted".to_string()),
112 )
113 .await?;
114 continue;
115 };
116 let secret = decrypt(self.encryption_key(), &hook.secret)
117 .unwrap_or_else(|_| hook.secret.clone());
118 let result = self.post_repo_payload(&hook.url, &secret, &delivery).await;
119 match result {
120 Ok((status, body)) => {
121 self.finish_repo_delivery(
122 delivery.id,
123 RepoWebhookDeliveryStatus::Done,
124 delivery.attempts + 1,
125 Some(status),
126 Some(body),
127 None,
128 )
129 .await?;
130 }
131 Err(error) => {
132 let attempts = delivery.attempts + 1;
133 if attempts >= max_attempts {
134 self.finish_repo_delivery(
135 delivery.id,
136 RepoWebhookDeliveryStatus::Failed,
137 attempts,
138 None,
139 None,
140 Some(error),
141 )
142 .await?;
143 } else {
144 let next = OffsetDateTime::now_utc()
145 + time::Duration::seconds(backoff_base * i64::from(attempts));
146 let mut am: repo_webhook_delivery::ActiveModel = delivery.clone().into();
147 am.status = Set(RepoWebhookDeliveryStatus::Pending);
148 am.attempts = Set(attempts);
149 am.next_retry_at = Set(next);
150 am.last_error = Set(Some(error));
151 am.updated_at = Set(OffsetDateTime::now_utc());
152 am.update(self.db_write()).await?;
153 }
154 }
155 }
156 }
157 Ok(count)
158 }
159
160 async fn finish_repo_delivery(
161 &self,
162 id: Uuid,
163 status: RepoWebhookDeliveryStatus,
164 attempts: i32,
165 response_status: Option<i32>,
166 response_body: Option<String>,
167 last_error: Option<String>,
168 ) -> CiResult<()> {
169 let am = repo_webhook_delivery::ActiveModel {
170 id: Set(id),
171 status: Set(status),
172 attempts: Set(attempts),
173 response_status: Set(response_status),
174 response_body: Set(response_body),
175 last_error: Set(last_error),
176 updated_at: Set(OffsetDateTime::now_utc()),
177 ..Default::default()
178 };
179 am.update(self.db_write()).await?;
180 Ok(())
181 }
182
183 async fn post_repo_payload(
184 &self,
185 url: &str,
186 secret: &str,
187 delivery: &repo_webhook_delivery::Model,
188 ) -> Result<(i32, String), String> {
189 let uri: reqwest::Url = url.parse().map_err(|e| format!("invalid url: {e}"))?;
190 if uri.scheme() != "https" && uri.scheme() != "http" {
191 return Err("webhook url must be http(s)".to_string());
192 }
193 let host = uri.host_str().unwrap_or("").to_string();
194 if host.is_empty() {
195 return Err("webhook url has no host".to_string());
196 }
197 let port = uri
198 .port()
199 .unwrap_or(if uri.scheme() == "https" { 443 } else { 80 });
200 let addrs = tokio::net::lookup_host((host.as_str(), port))
201 .await
202 .map_err(|e| format!("dns resolution failed: {e}"))?
203 .collect::<Vec<_>>();
204 if addrs.is_empty() {
205 return Err("dns resolution returned no address".to_string());
206 }
207 if addrs
208 .iter()
209 .any(|addr| !crate::net::is_public_ip(&addr.ip()))
210 {
211 return Err("url resolves to a private or loopback address".to_string());
212 }
213 let body = serde_json::to_vec(&delivery.payload).map_err(|e| e.to_string())?;
214 let mut mac =
215 Hmac::<Sha256>::new_from_slice(secret.as_bytes()).map_err(|e| e.to_string())?;
216 mac.update(&body);
217 let signature = format!(
218 "sha256={}",
219 crate::secrets::sha256_hex(&mac.finalize().into_bytes())
220 );
221 let mut builder = reqwest::Client::builder()
222 .timeout(std::time::Duration::from_secs(
223 self.config().ci.outbound_timeout_secs.max(1),
224 ))
225 .redirect(reqwest::redirect::Policy::none());
226 for addr in &addrs {
227 builder = builder.resolve(&host, *addr);
228 }
229 let client = builder.build().map_err(|e| e.to_string())?;
230 let response = client
231 .post(url)
232 .header("content-type", "application/json")
233 .header("x-gitdata-event", &delivery.event_type)
234 .header("x-gitdata-delivery", delivery.id.to_string())
235 .header("x-gitdata-signature", signature)
236 .body(body)
237 .send()
238 .await
239 .map_err(|e| format!("request failed: {e}"))?;
240 let status = response.status().as_u16() as i32;
241 let text = response
242 .text()
243 .await
244 .unwrap_or_default()
245 .chars()
246 .take(MAX_RESPONSE_BODY)
247 .collect::<String>();
248 if (200..300).contains(&status) {
249 Ok((status, text))
250 } else {
251 Err(format!("endpoint returned {status}"))
252 }
253 }
254}
Added lib/ci/src/routes.rs View lib/ci/src/routes.rs
@@ -0,0 +1,94 @@
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",
11 get(runs::list_runs),
12 )
13 .route(
14 "/repos/{space_name}/{repo_name}/pipelines/workflows",
15 get(runs::list_workflows),
16 )
17 .route(
18 "/repos/{space_name}/{repo_name}/pipelines/trigger",
19 post(runs::trigger),
20 )
21 .route(
22 "/repos/{space_name}/{repo_name}/pipelines/artifacts/{artifact_id}/download",
23 get(artifacts::download_artifact),
24 )
25 .route(
26 "/repos/{space_name}/{repo_name}/pipelines/{run_id}",
27 get(runs::get_run),
28 )
29 .route(
30 "/repos/{space_name}/{repo_name}/pipelines/{run_id}/cancel",
31 post(runs::cancel_run),
32 )
33 .route(
34 "/repos/{space_name}/{repo_name}/pipelines/{run_id}/rerun",
35 post(runs::rerun),
36 )
37 .route(
38 "/repos/{space_name}/{repo_name}/pipelines/{run_id}/artifacts",
39 get(artifacts::list_artifacts),
40 )
41 .route(
42 "/repos/{space_name}/{repo_name}/pipelines/{run_id}/jobs/{job_id}/logs",
43 get(logs::read_logs),
44 )
45 .route(
46 "/repos/{space_name}/{repo_name}/secrets",
47 get(space::list_repo_secrets),
48 )
49 .route(
50 "/repos/{space_name}/{repo_name}/secrets/{name}",
51 put(space::upsert_repo_secret).delete(space::delete_repo_secret),
52 )
53 .route(
54 "/spaces/{space_name}/secrets",
55 get(space::list_space_secrets),
56 )
57 .route(
58 "/spaces/{space_name}/environments",
59 get(space::list_environments),
60 )
61 .route(
62 "/spaces/{space_name}/environments/{name}/deployments",
63 get(space::list_deployments),
64 )
65}
66
67pub fn runner_router() -> Router<CiService> {
68 Router::new()
69 .route("/runner/register", post(runner_protocol::register))
70 .route("/runner/heartbeat", post(runner_protocol::heartbeat))
71 .route("/runner/jobs/claim", post(runner_protocol::claim))
72 .route("/runner/jobs/{job_id}/start", post(runner_protocol::start))
73 .route("/runner/jobs/{job_id}/renew", post(runner_protocol::renew))
74 .route(
75 "/runner/jobs/{job_id}/steps/{index}",
76 post(runner_protocol::report_step),
77 )
78 .route(
79 "/runner/jobs/{job_id}/logs",
80 post(runner_protocol::upload_logs),
81 )
82 .route(
83 "/runner/jobs/{job_id}/finish",
84 post(runner_protocol::finish),
85 )
86 .route(
87 "/runner/jobs/{job_id}/artifacts/init",
88 post(runner_protocol::init_artifact),
89 )
90 .route(
91 "/runner/jobs/{job_id}/artifacts/{artifact_id}/complete",
92 post(runner_protocol::complete_artifact),
93 )
94}
Added lib/ci/src/runners.rs View lib/ci/src/runners.rs
@@ -0,0 +1,439 @@
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::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
12use sha2::{Digest, Sha256};
13use time::OffsetDateTime;
14use uuid::Uuid;
15
16use crate::errors::{CiError, CiResult};
17use crate::secrets::{constant_time_eq, random_secret};
18use crate::service::CiService;
19
20#[derive(Debug, Clone)]
21pub struct RunnerAuth {
22 pub runner_id: Uuid,
23}
24
25pub fn hash_token(secret: &str, salt: &str) -> String {
26 let digest = Sha256::digest(format!("{secret}{salt}").as_bytes());
27 let mut out = String::with_capacity(digest.len() * 2);
28 for byte in digest {
29 out.push_str(&format!("{byte:02x}"));
30 }
31 out
32}
33
34pub fn generate_credential(runner_id: Uuid) -> (String, String, String, String) {
35 let secret = random_secret(32);
36 let salt = random_secret(16);
37 let hash = hash_token(&secret, &salt);
38 let last_eight: String = secret
39 .chars()
40 .rev()
41 .take(8)
42 .collect::<String>()
43 .chars()
44 .rev()
45 .collect();
46 let token = format!("{runner_id}.{secret}");
47 (token, hash, salt, last_eight)
48}
49
50pub async fn verify_runner_token(service: &CiService, token: &str) -> CiResult<Uuid> {
51 let Some((id, secret)) = token.split_once('.') else {
52 return Err(CiError::Unauthorized);
53 };
54 let Ok(runner_id) = Uuid::parse_str(id) else {
55 return Err(CiError::Unauthorized);
56 };
57 let row = runner_token::Entity::find()
58 .filter(runner_token::Column::RunnerId.eq(runner_id))
59 .one(service.db_read())
60 .await?;
61 let Some(row) = row else {
62 return Err(CiError::Unauthorized);
63 };
64 if row.revoked_at.is_some() {
65 return Err(CiError::Unauthorized);
66 }
67 if let Some(expires_at) = row.expires_at
68 && expires_at <= OffsetDateTime::now_utc()
69 {
70 return Err(CiError::Unauthorized);
71 }
72 let computed = hash_token(secret, &row.token_salt);
73 if !constant_time_eq(computed.as_bytes(), row.token_hash.as_bytes()) {
74 return Err(CiError::Unauthorized);
75 }
76 let runner = runner::Entity::find_by_id(runner_id)
77 .one(service.db_read())
78 .await?
79 .ok_or(CiError::Unauthorized)?;
80 if runner.state != RunnerState::Active {
81 return Err(CiError::Forbidden);
82 }
83 Ok(runner_id)
84}
85
86impl FromRequestParts<CiService> for RunnerAuth {
87 type Rejection = crate::errors::ApiError;
88
89 async fn from_request_parts(
90 parts: &mut axum::http::request::Parts,
91 state: &CiService,
92 ) -> Result<Self, Self::Rejection> {
93 let Some(value) = parts.headers.get(header::AUTHORIZATION) else {
94 return Err(crate::errors::ApiError::unauthorized());
95 };
96 let raw = value
97 .to_str()
98 .map_err(|_| crate::errors::ApiError::bad_request("malformed authorization header"))?;
99 let Some(token) = raw.strip_prefix("Bearer ") else {
100 return Err(crate::errors::ApiError::unauthorized());
101 };
102 let runner_id = verify_runner_token(state, token)
103 .await
104 .map_err(crate::errors::ApiError::from)?;
105 Ok(Self { runner_id })
106 }
107}
108
109impl CiService {
110 pub async fn register_runner(
111 &self,
112 token: &str,
113 name: &str,
114 labels: Vec<String>,
115 capacity: i32,
116 version: Option<String>,
117 ) -> CiResult<RegisterResponse> {
118 self.ensure_enabled()?;
119 if name.trim().is_empty() || name.len() > 200 {
120 return Err(CiError::InvalidArgument(
121 "runner name must be 1-200 characters".to_string(),
122 ));
123 }
124 let mut labels: Vec<String> = labels
125 .into_iter()
126 .map(|l| l.trim().to_string())
127 .filter(|l| !l.is_empty())
128 .collect();
129 labels.sort();
130 labels.dedup();
131 if labels.is_empty() {
132 return Err(CiError::InvalidArgument(
133 "runner must declare at least one label".to_string(),
134 ));
135 }
136 if capacity <= 0 || capacity > 256 {
137 return Err(CiError::InvalidArgument(
138 "runner capacity must be between 1 and 256".to_string(),
139 ));
140 }
141
142 let (registration_token, space_scope) = self.verify_registration_token(token).await?;
143
144 let now = OffsetDateTime::now_utc();
145 let runner_id = Uuid::now_v7();
146 runner::ActiveModel {
147 id: Set(runner_id),
148 name: Set(name.trim().to_string()),
149 labels: Set(serde_json::to_value(&labels)?),
150 capacity: Set(capacity),
151 version: Set(version),
152 state: Set(RunnerState::Active),
153 space_id: Set(space_scope),
154 last_seen_at: Set(Some(now)),
155 created_at: Set(now),
156 updated_at: Set(now),
157 }
158 .insert(self.db_write())
159 .await?;
160
161 let (credential, hash, salt, last_eight) = generate_credential(runner_id);
162 runner_token::ActiveModel {
163 id: Set(Uuid::now_v7()),
164 runner_id: Set(Some(runner_id)),
165 name: Set(format!("runner:{name}")),
166 token_hash: Set(hash),
167 token_salt: Set(salt),
168 token_last_eight: Set(last_eight),
169 space_id: Set(space_scope),
170 single_use: Set(false),
171 used_at: Set(None),
172 revoked_at: Set(None),
173 expires_at: Set(None),
174 created_at: Set(now),
175 updated_at: Set(now),
176 }
177 .insert(self.db_write())
178 .await?;
179
180 if let Some(registration_token_id) = registration_token {
181 let mut am: runner_token::ActiveModel = registration_token_id.into();
182 am.used_at = Set(Some(now));
183 am.updated_at = Set(now);
184 let _ = am.update(self.db_write()).await;
185 }
186
187 tracing::info!(runner_id = %runner_id, name, "runner registered");
188 Ok(RegisterResponse {
189 runner_id,
190 credential,
191 heartbeat_interval_secs: self.config().ci.heartbeat_interval_secs,
192 poll_timeout_secs: self.config().ci.long_poll_timeout_secs,
193 protocol_version: ci_protocol::PROTOCOL_VERSION,
194 })
195 }
196
197 async fn verify_registration_token(
198 &self,
199 token: &str,
200 ) -> CiResult<(Option<runner_token::Model>, Option<Uuid>)> {
201 let Some((id, secret)) = token.split_once('.') else {
202 return Err(CiError::Unauthorized);
203 };
204 let Ok(token_id) = Uuid::parse_str(id) else {
205 return Err(CiError::Unauthorized);
206 };
207 let row = runner_token::Entity::find_by_id(token_id)
208 .one(self.db_read())
209 .await?
210 .ok_or(CiError::Unauthorized)?;
211 if row.runner_id.is_some() || row.revoked_at.is_some() {
212 return Err(CiError::Unauthorized);
213 }
214 if row.single_use && row.used_at.is_some() {
215 return Err(CiError::Unauthorized);
216 }
217 if let Some(expires_at) = row.expires_at
218 && expires_at <= OffsetDateTime::now_utc()
219 {
220 return Err(CiError::Unauthorized);
221 }
222 let computed = hash_token(secret, &row.token_salt);
223 if !constant_time_eq(computed.as_bytes(), row.token_hash.as_bytes()) {
224 return Err(CiError::Unauthorized);
225 }
226 Ok((Some(row.clone()), row.space_id))
227 }
228
229 pub async fn runner_heartbeat(&self, runner_id: Uuid) -> CiResult<Vec<Uuid>> {
230 let now = OffsetDateTime::now_utc();
231 runner::Entity::update_many()
232 .col_expr(
233 runner::Column::LastSeenAt,
234 sea_orm::sea_query::Expr::value(Some(now)),
235 )
236 .col_expr(
237 runner::Column::UpdatedAt,
238 sea_orm::sea_query::Expr::value(now),
239 )
240 .filter(runner::Column::Id.eq(runner_id))
241 .exec(self.db_write())
242 .await?;
243 self.cancel_list_for_runner(runner_id).await
244 }
245
246 pub async fn runner_labels(&self, runner_id: Uuid) -> CiResult<Vec<String>> {
247 let runner = runner::Entity::find_by_id(runner_id)
248 .one(self.db_read())
249 .await?
250 .ok_or(CiError::NotFound)?;
251 Ok(serde_json::from_value(runner.labels).unwrap_or_default())
252 }
253
254 /// Builds the full claim payload for a job that this runner has leased.
255 pub async fn build_job_payload(
256 &self,
257 job_id: Uuid,
258 _runner_id: Uuid,
259 lease_token: String,
260 ) -> CiResult<ci_protocol::runner::JobPayload> {
261 let job = workflow_job::Entity::find_by_id(job_id)
262 .one(self.db_read())
263 .await?
264 .ok_or(CiError::NotFound)?;
265 let run = workflow_run::Entity::find_by_id(job.run_id)
266 .one(self.db_read())
267 .await?
268 .ok_or(CiError::NotFound)?;
269 let repo = self.repo_by_id(job.repo_id).await?;
270 let space = self.space_of_repo(&repo).await?;
271 let definition: serde_json::Value = job.definition.clone();
272
273 let steps = workflow_step::Entity::find()
274 .filter(workflow_step::Column::JobId.eq(job_id))
275 .order_by_asc(workflow_step::Column::StepIndex)
276 .all(self.db_read())
277 .await?;
278 let step_env: BTreeMap<String, String> = definition
279 .get("env")
280 .and_then(|v| serde_json::from_value(v.clone()).ok())
281 .unwrap_or_default();
282 let mut step_payloads = Vec::with_capacity(steps.len());
283 for step in steps {
284 step_payloads.push(StepPayload {
285 index: step.step_index,
286 name: step.name.clone(),
287 run: step.script.clone(),
288 shell: step.shell.clone(),
289 working_directory: step.working_directory.clone(),
290 env: step_env.clone(),
291 timeout_secs: definition
292 .get("timeout-minutes")
293 .and_then(|v| v.as_u64())
294 .map(|m| m * 60),
295 });
296 }
297
298 let workflow_env: BTreeMap<String, String> = run
299 .definition
300 .get("env")
301 .and_then(|v| serde_json::from_value(v.clone()).ok())
302 .unwrap_or_default();
303 let mut env = BTreeMap::new();
304 env.insert("GITDATA_RUN_ID".to_string(), run.id.to_string());
305 env.insert("GITDATA_JOB_ID".to_string(), job.id.to_string());
306 env.insert("GITDATA_REPO_ID".to_string(), repo.id.to_string());
307 env.insert("GITDATA_SPACE".to_string(), space.lower_name.clone());
308 env.insert("GITDATA_REPO".to_string(), repo.name.clone());
309 env.insert("GITDATA_SHA".to_string(), run.commit_sha.clone());
310 env.insert("GITDATA_REF".to_string(), run.git_ref.clone());
311 env.insert("GITDATA_EVENT".to_string(), run.event.as_str().to_string());
312 env.insert("GITDATA_WORKFLOW".to_string(), run.workflow_name.clone());
313 env.insert("CI".to_string(), "true".to_string());
314 env.insert("GITDATA_CI".to_string(), "true".to_string());
315 if let Some(branch) = &run.branch {
316 env.insert("GITDATA_BRANCH".to_string(), branch.clone());
317 }
318 if let Some(tag) = &run.tag {
319 env.insert("GITDATA_TAG".to_string(), tag.clone());
320 }
321 if let Some(base_sha) = &run.base_sha {
322 env.insert("GITDATA_BASE_SHA".to_string(), base_sha.clone());
323 }
324 if let Some(base_ref) = &run.base_ref {
325 env.insert("GITDATA_BASE_REF".to_string(), base_ref.clone());
326 }
327 if let Some(number) = run.pr_number {
328 env.insert("GITDATA_PR_NUMBER".to_string(), number.to_string());
329 }
330 if let Some(environment) = &job.environment {
331 env.insert("GITDATA_ENVIRONMENT".to_string(), environment.clone());
332 }
333 for (key, value) in workflow_env {
334 env.insert(key, value);
335 }
336
337 let secrets: BTreeMap<String, String> = self
338 .resolved_secrets(repo.id, space.id, job.environment.as_deref())
339 .await?
340 .into_iter()
341 .collect();
342
343 let contents_write = definition
344 .get("permissions")
345 .and_then(|p| p.get("contents"))
346 .and_then(|c| c.as_str())
347 .is_some_and(|c| c == "write");
348
349 let artifacts: Vec<ci_protocol::runner::ArtifactSpecDto> = definition
350 .get("artifacts")
351 .and_then(|v| serde_json::from_value(v.clone()).ok())
352 .unwrap_or_default();
353
354 let needs_artifacts = self.needs_artifacts(run.id, job.id).await?;
355 let artifacts: Vec<ci_protocol::runner::ArtifactSpecDto> = artifacts
356 .into_iter()
357 .map(|spec| ci_protocol::runner::ArtifactSpecDto {
358 name: spec.name,
359 path: spec.path,
360 always: spec.always,
361 })
362 .collect();
363
364 let clone_url = format!(
365 "{}/{}/{}.git",
366 self.config().git.base_url.trim_end_matches('/'),
367 space.lower_name,
368 repo.name
369 );
370
371 let payload = ci_protocol::runner::JobPayload {
372 job_id: job.id,
373 run_id: run.id,
374 repo_id: repo.id,
375 space_id: space.id,
376 run_attempt: job.attempt,
377 job_name: job.name.clone(),
378 clone_url,
379 commit_sha: run.commit_sha.clone(),
380 git_ref: run.git_ref.clone(),
381 branch: run.branch.clone(),
382 tag: run.tag.clone(),
383 base_sha: run.base_sha.clone(),
384 base_ref: run.base_ref.clone(),
385 pr_number: run.pr_number,
386 event: run.event.as_str().to_string(),
387 workflow_name: run.workflow_name.clone(),
388 workflow_file: run.workflow_file.clone(),
389 env,
390 secrets,
391 allow_git_token: contents_write,
392 container: definition
393 .get("container")
394 .and_then(|v| v.as_str())
395 .map(|s| s.to_string()),
396 timeout_secs: definition
397 .get("timeout-minutes")
398 .and_then(|v| v.as_u64())
399 .map(|m| m * 60)
400 .unwrap_or(self.config().ci.job_timeout_default_secs)
401 .min(self.config().ci.job_timeout_max_secs),
402 steps: step_payloads,
403 artifacts,
404 needs_artifacts,
405 lease: LeaseDto {
406 token: lease_token,
407 expires_at: OffsetDateTime::now_utc()
408 + time::Duration::seconds(self.config().ci.lease_secs as i64),
409 heartbeat_interval_secs: self.config().ci.heartbeat_interval_secs,
410 attempt: job.attempt,
411 },
412 };
413 Ok(payload)
414 }
415}
416
417impl CiService {
418 /// Returns the claim payload (with a fresh lease token) for a leased job.
419 pub async fn claim_payload(
420 &self,
421 job_id: Uuid,
422 runner_id: Uuid,
423 ) -> CiResult<Option<ci_protocol::runner::JobPayload>> {
424 let job = workflow_job::Entity::find_by_id(job_id)
425 .one(self.db_read())
426 .await?
427 .ok_or(CiError::NotFound)?;
428 if job.runner_id != Some(runner_id) {
429 return Ok(None);
430 }
431 let lease_token = job
432 .lease_token
433 .clone()
434 .unwrap_or_else(|| crate::secrets::random_secret(24));
435 self.build_job_payload(job_id, runner_id, lease_token)
436 .await
437 .map(Some)
438 }
439}
Added lib/ci/src/runs.rs View lib/ci/src/runs.rs
@@ -0,0 +1,357 @@
1use std::collections::BTreeMap;
2
3use ci_protocol::dsl::{Job, WorkflowDefinition};
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, pull_request_trigger_matches, push_trigger_matches};
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<Uuid>,
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 }),
71 commit_sha: update.new_oid.clone(),
72 git_ref: update.name.clone(),
73 branch,
74 tag,
75 base_sha: None,
76 base_ref: None,
77 pr_number: None,
78 trigger_user_id: event.actor_id,
79 });
80 }
81 out
82}
83
84pub fn pull_request_seed(event: &PullRequestEvent) -> RunSeed {
85 RunSeed {
86 event: WorkflowEventKind::PullRequest,
87 event_payload: serde_json::json!({
88 "action": event.action.as_str(),
89 "number": event.number,
90 "head_ref": event.head_ref,
91 "base_ref": event.base_ref,
92 "head_sha": event.head_sha,
93 "base_sha": event.base_sha,
94 "actor_id": event.actor_id,
95 }),
96 commit_sha: event.head_sha.clone(),
97 git_ref: format!("refs/pull/{}/head", event.number),
98 branch: Some(event.head_ref.clone()),
99 tag: None,
100 base_sha: Some(event.base_sha.clone()),
101 base_ref: Some(event.base_ref.clone()),
102 pr_number: Some(event.number),
103 trigger_user_id: event.actor_id,
104 }
105}
106
107impl CiService {
108 /// Evaluates every workflow in the repo at the event revision and creates
109 /// the runs whose triggers match.
110 pub async fn handle_event(&self, event: &CiEvent) -> CiResult<Vec<Uuid>> {
111 if !self.enabled() {
112 return Ok(Vec::new());
113 }
114 let (repo_id, space_id, seeds) = match event {
115 CiEvent::Push(push) => (push.repo_id, push.space_id, push_seeds(push)),
116 CiEvent::PullRequest(pr) => (pr.repo_id, pr.space_id, vec![pull_request_seed(pr)]),
117 };
118 let repo = repos::Entity::find_by_id(repo_id)
119 .one(self.db_read())
120 .await?
121 .ok_or(CiError::NotFound)?;
122 let space = spaces::Entity::find_by_id(space_id)
123 .one(self.db_read())
124 .await?
125 .ok_or(CiError::NotFound)?;
126
127 let mut created = Vec::new();
128 for seed in seeds {
129 let workflows = self
130 .load_valid_workflows(&space, &repo.name, &seed.commit_sha)
131 .await?;
132 for workflow in workflows {
133 if !matches_trigger(&workflow, event, &seed) {
134 continue;
135 }
136 let run_id = self.create_run(&repo, &space, &seed, &workflow).await?;
137 created.push(run_id);
138 }
139 }
140 Ok(created)
141 }
142
143 pub async fn create_run(
144 &self,
145 repo: &repos::Model,
146 space: &spaces::Model,
147 seed: &RunSeed,
148 workflow: &LoadedWorkflow,
149 ) -> CiResult<Uuid> {
150 let run_id = Uuid::now_v7();
151 let now = OffsetDateTime::now_utc();
152 let definition = serde_json::to_value(&workflow.definition)?;
153 let concurrency_group = workflow
154 .definition
155 .concurrency
156 .as_ref()
157 .map(|c| expand_concurrency_group(&c.group, seed));
158
159 let txn = self.db_write().begin().await?;
160 workflow_run::ActiveModel {
161 id: Set(run_id),
162 repo_id: Set(repo.id),
163 trigger_user_id: Set(seed.trigger_user_id),
164 workflow_file: Set(workflow.file.clone()),
165 workflow_name: Set(workflow.name.clone()),
166 event: Set(seed.event),
167 event_payload: Set(seed.event_payload.clone()),
168 definition: Set(definition.clone()),
169 commit_sha: Set(seed.commit_sha.clone()),
170 git_ref: Set(seed.git_ref.clone()),
171 branch: Set(seed.branch.clone()),
172 tag: Set(seed.tag.clone()),
173 base_sha: Set(seed.base_sha.clone()),
174 base_ref: Set(seed.base_ref.clone()),
175 pr_number: Set(seed.pr_number),
176 state: Set(WorkflowRunState::Queued),
177 concurrency_group: Set(concurrency_group),
178 runner_id: Set(None),
179 attempt: Set(0),
180 started_at: Set(None),
181 finished_at: Set(None),
182 created_at: Set(now),
183 updated_at: Set(now),
184 }
185 .insert(&txn)
186 .await?;
187
188 for (job_key, job) in &workflow.definition.jobs {
189 let job_id = Uuid::now_v7();
190 let environment = job.environment.clone();
191 let facts = seed.facts(environment.as_deref());
192 let condition_ok = match &job.condition {
193 Some(condition) => crate::share::conditions::condition_matches(condition, &facts),
194 None => true,
195 };
196 let state = if condition_ok {
197 if job.needs.is_empty() {
198 WorkflowJobState::Queued
199 } else {
200 WorkflowJobState::Pending
201 }
202 } else {
203 WorkflowJobState::Skipped
204 };
205 workflow_job::ActiveModel {
206 id: Set(job_id),
207 run_id: Set(run_id),
208 repo_id: Set(repo.id),
209 space_id: Set(space.id),
210 name: Set(job_key.clone()),
211 job_key: Set(job_key.clone()),
212 definition: Set(serde_json::to_value(job)?),
213 runs_on: Set(serde_json::to_value(&job.runs_on)?),
214 environment: Set(environment),
215 needs: Set(serde_json::to_value(&job.needs)?),
216 state: Set(state),
217 runner_id: Set(None),
218 lease_token: Set(None),
219 lease_expires_at: Set(None),
220 attempt: Set(0),
221 exit_code: Set(None),
222 error: Set(None),
223 started_at: Set(None),
224 finished_at: Set(None),
225 created_at: Set(now),
226 updated_at: Set(now),
227 }
228 .insert(&txn)
229 .await?;
230
231 for (index, step) in job.steps.iter().enumerate() {
232 workflow_step::ActiveModel {
233 id: Set(Uuid::now_v7()),
234 job_id: Set(job_id),
235 step_index: Set(index as i32),
236 name: Set(step
237 .name
238 .clone()
239 .unwrap_or_else(|| format!("step {}", index + 1))),
240 script: Set(step.run.clone()),
241 shell: Set(step.shell.clone()),
242 working_directory: Set(step.working_directory.clone()),
243 state: Set(WorkflowStepState::Pending),
244 exit_code: Set(None),
245 started_at: Set(None),
246 finished_at: Set(None),
247 created_at: Set(now),
248 updated_at: Set(now),
249 }
250 .insert(&txn)
251 .await?;
252 }
253 }
254
255 txn.commit().await?;
256 tracing::info!(
257 run_id = %run_id,
258 repo_id = %repo.id,
259 workflow = %workflow.file,
260 commit = %seed.commit_sha,
261 "workflow run created"
262 );
263 Ok(run_id)
264 }
265
266 pub async fn rerun(&self, run_id: Uuid, caller: Uuid) -> CiResult<Uuid> {
267 let run = workflow_run::Entity::find_by_id(run_id)
268 .one(self.db_read())
269 .await?
270 .ok_or(CiError::NotFound)?;
271 if !matches!(
272 run.state,
273 WorkflowRunState::Success | WorkflowRunState::Failure | WorkflowRunState::Cancelled
274 ) {
275 return Err(CiError::Conflict(
276 "only finished runs can be rerun".to_string(),
277 ));
278 }
279 let repo = repos::Entity::find_by_id(run.repo_id)
280 .one(self.db_read())
281 .await?
282 .ok_or(CiError::NotFound)?;
283 let space = self.space_of_repo(&repo).await?;
284 let definition: WorkflowDefinition = serde_json::from_value(run.definition.clone())?;
285 let workflow = LoadedWorkflow {
286 file: run.workflow_file.clone(),
287 name: run.workflow_name.clone(),
288 definition,
289 };
290 let seed = RunSeed {
291 event: run.event,
292 event_payload: run.event_payload.clone(),
293 commit_sha: run.commit_sha.clone(),
294 git_ref: run.git_ref.clone(),
295 branch: run.branch.clone(),
296 tag: run.tag.clone(),
297 base_sha: run.base_sha.clone(),
298 base_ref: run.base_ref.clone(),
299 pr_number: run.pr_number,
300 trigger_user_id: Some(caller),
301 };
302 self.create_run(&repo, &space, &seed, &workflow).await
303 }
304}
305
306fn matches_trigger(workflow: &LoadedWorkflow, event: &CiEvent, seed: &RunSeed) -> bool {
307 let triggers = &workflow.definition.on;
308 match event {
309 CiEvent::Push(_) => match triggers.push.as_ref() {
310 Some(trigger) => {
311 push_trigger_matches(trigger, seed.branch.as_deref(), seed.tag.as_deref())
312 }
313 None => false,
314 },
315 CiEvent::PullRequest(pr) => match triggers.pull_request.as_ref() {
316 Some(trigger) => pull_request_trigger_matches(
317 trigger,
318 seed.base_ref.as_deref().unwrap_or_default(),
319 pr.action,
320 ),
321 None => false,
322 },
323 }
324}
325
326#[must_use]
327pub fn expand_concurrency_group(group: &str, seed: &RunSeed) -> String {
328 let ref_name = seed
329 .branch
330 .clone()
331 .or_else(|| seed.tag.clone())
332 .unwrap_or_else(|| seed.git_ref.clone());
333 group
334 .replace("{ref}", &ref_name)
335 .replace("{branch}", seed.branch.as_deref().unwrap_or_default())
336 .replace("{tag}", seed.tag.as_deref().unwrap_or_default())
337 .replace("{event}", seed.event.as_str())
338}
339
340#[must_use]
341pub fn job_from_definition(run_definition: &serde_json::Value, job_key: &str) -> Option<Job> {
342 let jobs: BTreeMap<String, Job> =
343 serde_json::from_value(run_definition.get("jobs")?.clone()).ok()?;
344 jobs.get(job_key).cloned()
345}
346
347pub async fn touch_run(db: &DatabaseConnection, run_id: Uuid) -> CiResult<()> {
348 workflow_run::Entity::update_many()
349 .col_expr(
350 workflow_run::Column::UpdatedAt,
351 sea_orm::sea_query::Expr::value(OffsetDateTime::now_utc()),
352 )
353 .filter(workflow_run::Column::Id.eq(run_id))
354 .exec(db)
355 .await?;
356 Ok(())
357}
Added lib/ci/src/scheduler.rs View lib/ci/src/scheduler.rs
@@ -0,0 +1,985 @@
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 pub async fn advance_jobs(&self) -> CiResult<u64> {
47 let runs = workflow_run::Entity::find()
48 .filter(
49 workflow_run::Column::State
50 .is_in([WorkflowRunState::Queued, WorkflowRunState::Running]),
51 )
52 .all(self.db_read())
53 .await?;
54 let mut moved = 0u64;
55 for run in runs {
56 moved += self.advance_run(run.id).await?;
57 }
58 Ok(moved)
59 }
60
61 async fn advance_run(&self, run_id: Uuid) -> CiResult<u64> {
62 let jobs = workflow_job::Entity::find()
63 .filter(workflow_job::Column::RunId.eq(run_id))
64 .all(self.db_read())
65 .await?;
66 let by_key: HashMap<String, WorkflowJobState> = jobs
67 .iter()
68 .map(|job| (job.job_key.clone(), job.state))
69 .collect();
70 let mut moved = 0u64;
71 let now = OffsetDateTime::now_utc();
72 for job in &jobs {
73 if job.state != WorkflowJobState::Pending {
74 continue;
75 }
76 let needs: Vec<String> = serde_json::from_value(job.needs.clone()).unwrap_or_default();
77 if needs.is_empty() {
78 continue;
79 }
80 let mut ready = true;
81 let mut blocked = false;
82 for need in &needs {
83 match by_key.get(need) {
84 Some(WorkflowJobState::Success) => {}
85 Some(
86 WorkflowJobState::Failure
87 | WorkflowJobState::TimedOut
88 | WorkflowJobState::Cancelled
89 | WorkflowJobState::Skipped,
90 ) => blocked = true,
91 _ => ready = false,
92 }
93 }
94 if blocked {
95 let updated = workflow_job::Entity::update_many()
96 .col_expr(
97 workflow_job::Column::State,
98 Expr::value(WorkflowJobState::Skipped),
99 )
100 .col_expr(workflow_job::Column::FinishedAt, Expr::value(Some(now)))
101 .col_expr(workflow_job::Column::UpdatedAt, Expr::value(now))
102 .filter(workflow_job::Column::Id.eq(job.id))
103 .filter(workflow_job::Column::State.eq(WorkflowJobState::Pending))
104 .exec(self.db_write())
105 .await?;
106 if updated.rows_affected > 0 {
107 moved += 1;
108 self.skip_steps(job.id).await?;
109 }
110 continue;
111 }
112 if !ready {
113 continue;
114 }
115 let updated = workflow_job::Entity::update_many()
116 .col_expr(
117 workflow_job::Column::State,
118 Expr::value(WorkflowJobState::Queued),
119 )
120 .col_expr(workflow_job::Column::UpdatedAt, Expr::value(now))
121 .filter(workflow_job::Column::Id.eq(job.id))
122 .filter(workflow_job::Column::State.eq(WorkflowJobState::Pending))
123 .exec(self.db_write())
124 .await?;
125 if updated.rows_affected > 0 {
126 moved += 1;
127 }
128 }
129 Ok(moved)
130 }
131
132 async fn skip_steps(&self, job_id: Uuid) -> CiResult<()> {
133 workflow_step::Entity::update_many()
134 .col_expr(
135 workflow_step::Column::State,
136 Expr::value(WorkflowStepState::Skipped),
137 )
138 .filter(workflow_step::Column::JobId.eq(job_id))
139 .filter(workflow_step::Column::State.eq(WorkflowStepState::Pending))
140 .exec(self.db_write())
141 .await?;
142 Ok(())
143 }
144
145 pub async fn enforce_concurrency(&self) -> CiResult<u64> {
146 let groups: Vec<GroupRow> = GroupRow::find_by_statement(Statement::from_string(
147 DatabaseBackend::Postgres,
148 "SELECT DISTINCT repo_id, concurrency_group FROM workflow_run \
149 WHERE concurrency_group IS NOT NULL \
150 AND state IN (0, 1, 4)"
151 .to_string(),
152 ))
153 .all(self.db_read())
154 .await?;
155
156 let mut affected = 0u64;
157 for group in groups {
158 let runs = workflow_run::Entity::find()
159 .filter(workflow_run::Column::RepoId.eq(group.repo_id))
160 .filter(workflow_run::Column::ConcurrencyGroup.eq(group.concurrency_group.clone()))
161 .filter(workflow_run::Column::State.is_in([
162 WorkflowRunState::Queued,
163 WorkflowRunState::Running,
164 WorkflowRunState::CancelRequested,
165 ]))
166 .order_by_asc(workflow_run::Column::CreatedAt)
167 .all(self.db_read())
168 .await?;
169 if runs.len() < 2 {
170 continue;
171 }
172 let oldest = &runs[0];
173 for run in runs.iter().skip(1) {
174 let cancel_in_progress = run
175 .definition
176 .get("concurrency")
177 .and_then(|c| c.get("cancel_in_progress"))
178 .and_then(|v| v.as_bool())
179 .unwrap_or(false);
180 if !cancel_in_progress {
181 continue;
182 }
183 if matches!(
184 oldest.state,
185 WorkflowRunState::Running | WorkflowRunState::CancelRequested
186 ) {
187 let now = OffsetDateTime::now_utc();
188 let updated = workflow_run::Entity::update_many()
189 .col_expr(
190 workflow_run::Column::State,
191 Expr::value(WorkflowRunState::CancelRequested),
192 )
193 .col_expr(workflow_run::Column::UpdatedAt, Expr::value(now))
194 .filter(workflow_run::Column::Id.eq(oldest.id))
195 .filter(workflow_run::Column::State.eq(WorkflowRunState::Running))
196 .exec(self.db_write())
197 .await?;
198 if updated.rows_affected > 0 {
199 affected += 1;
200 self.record_event(
201 oldest.id,
202 "workflow_run",
203 serde_json::json!({
204 "action": "cancel_requested",
205 "reason": "concurrency",
206 }),
207 )
208 .await;
209 }
210 }
211 break;
212 }
213 }
214 Ok(affected)
215 }
216
217 pub async fn claim_job(&self, runner_id: Uuid, labels: &[String]) -> CiResult<Option<Uuid>> {
218 let runner = models::ci::runner::Entity::find_by_id(runner_id)
219 .one(self.db_read())
220 .await?
221 .ok_or(CiError::NotFound)?;
222 if runner.state != models::ci::runner::RunnerState::Active {
223 return Err(CiError::Forbidden);
224 }
225 let active = workflow_job::Entity::find()
226 .filter(workflow_job::Column::RunnerId.eq(runner_id))
227 .filter(
228 workflow_job::Column::State
229 .is_in([WorkflowJobState::Claimed, WorkflowJobState::Running]),
230 )
231 .count(self.db_read())
232 .await? as i32;
233 if active >= runner.capacity.max(1) {
234 return Ok(None);
235 }
236 let jobs = workflow_job::Entity::find()
237 .filter(workflow_job::Column::State.eq(WorkflowJobState::Queued))
238 .order_by_asc(workflow_job::Column::CreatedAt)
239 .limit(200)
240 .all(self.db_read())
241 .await?;
242 let now = OffsetDateTime::now_utc();
243 for job in jobs {
244 let job_labels: Vec<String> =
245 serde_json::from_value(job.runs_on.clone()).unwrap_or_default();
246 if !labels.to_vec().contains_all(&job_labels) {
247 continue;
248 }
249 if let Some(space_id) = runner.space_id
250 && job.space_id != space_id
251 {
252 continue;
253 }
254 if let Some(environment) = &job.environment
255 && !self
256 .runner_matches_environment(job.space_id, environment, labels)
257 .await?
258 {
259 continue;
260 }
261 if !self
262 .environment_available(job.space_id, job.environment.as_ref())
263 .await?
264 {
265 continue;
266 }
267 let lease_token = crate::secrets::random_secret(24);
268 let lease_expires = now + time::Duration::seconds(self.config().ci.lease_secs as i64);
269 let updated = workflow_job::Entity::update_many()
270 .col_expr(
271 workflow_job::Column::State,
272 Expr::value(WorkflowJobState::Claimed),
273 )
274 .col_expr(workflow_job::Column::RunnerId, Expr::value(runner_id))
275 .col_expr(
276 workflow_job::Column::LeaseToken,
277 Expr::value(lease_token.clone()),
278 )
279 .col_expr(
280 workflow_job::Column::LeaseExpiresAt,
281 Expr::value(Some(lease_expires)),
282 )
283 .col_expr(workflow_job::Column::Attempt, Expr::value(job.attempt + 1))
284 .col_expr(workflow_job::Column::UpdatedAt, Expr::value(now))
285 .filter(workflow_job::Column::Id.eq(job.id))
286 .filter(workflow_job::Column::State.eq(WorkflowJobState::Queued))
287 .exec(self.db_write())
288 .await?;
289 if updated.rows_affected == 0 {
290 continue;
291 }
292 self.mark_run_running(job.run_id, runner_id).await?;
293 self.start_deployment(&job).await?;
294 tracing::info!(
295 job_id = %job.id,
296 run_id = %job.run_id,
297 runner_id = %runner_id,
298 attempt = job.attempt + 1,
299 "job claimed"
300 );
301 return Ok(Some(job.id));
302 }
303 Ok(None)
304 }
305
306 async fn runner_matches_environment(
307 &self,
308 space_id: Uuid,
309 environment: &str,
310 labels: &[String],
311 ) -> CiResult<bool> {
312 let row = models::ci::ci_environment::Entity::find()
313 .filter(models::ci::ci_environment::Column::SpaceId.eq(space_id))
314 .filter(models::ci::ci_environment::Column::Name.eq(environment.to_string()))
315 .one(self.db_read())
316 .await?;
317 let Some(row) = row else {
318 return Ok(true);
319 };
320 let required: Vec<String> =
321 serde_json::from_value(row.required_labels.clone()).unwrap_or_default();
322 Ok(labels.to_vec().contains_all(&required))
323 }
324
325 async fn environment_available(
326 &self,
327 space_id: Uuid,
328 environment: Option<&String>,
329 ) -> CiResult<bool> {
330 let Some(environment) = environment else {
331 return Ok(true);
332 };
333 let row = models::ci::ci_environment::Entity::find()
334 .filter(models::ci::ci_environment::Column::SpaceId.eq(space_id))
335 .filter(models::ci::ci_environment::Column::Name.eq(environment.to_string()))
336 .one(self.db_read())
337 .await?;
338 let Some(row) = row else {
339 return Ok(true);
340 };
341 let active = models::ci::ci_deployment::Entity::find()
342 .filter(models::ci::ci_deployment::Column::EnvironmentId.eq(row.id))
343 .filter(models::ci::ci_deployment::Column::State.is_in([
344 models::ci::ci_deployment::DeploymentState::Pending,
345 models::ci::ci_deployment::DeploymentState::Running,
346 ]))
347 .count(self.db_read())
348 .await?;
349 Ok(active == 0)
350 }
351
352 async fn start_deployment(&self, job: &workflow_job::Model) -> CiResult<()> {
353 let Some(environment) = &job.environment else {
354 return Ok(());
355 };
356 let env_row = models::ci::ci_environment::Entity::find()
357 .filter(models::ci::ci_environment::Column::SpaceId.eq(job.space_id))
358 .filter(models::ci::ci_environment::Column::Name.eq(environment.clone()))
359 .one(self.db_read())
360 .await?;
361 let Some(env_row) = env_row else {
362 return Ok(());
363 };
364 let run = workflow_run::Entity::find_by_id(job.run_id)
365 .one(self.db_read())
366 .await?
367 .ok_or(CiError::NotFound)?;
368 let now = OffsetDateTime::now_utc();
369 let existing = models::ci::ci_deployment::Entity::find()
370 .filter(models::ci::ci_deployment::Column::JobId.eq(job.id))
371 .one(self.db_read())
372 .await?;
373 if existing.is_some() {
374 return Ok(());
375 }
376 models::ci::ci_deployment::ActiveModel {
377 id: Set(Uuid::now_v7()),
378 environment_id: Set(env_row.id),
379 run_id: Set(job.run_id),
380 job_id: Set(job.id),
381 repo_id: Set(job.repo_id),
382 commit_sha: Set(run.commit_sha.clone()),
383 git_ref: Set(run.git_ref.clone()),
384 state: Set(models::ci::ci_deployment::DeploymentState::Running),
385 actor_id: Set(run.trigger_user_id),
386 created_at: Set(now),
387 updated_at: Set(now),
388 }
389 .insert(self.db_write())
390 .await?;
391 Ok(())
392 }
393
394 pub async fn finish_deployment(
395 &self,
396 job_id: Uuid,
397 state: models::ci::ci_deployment::DeploymentState,
398 ) -> CiResult<()> {
399 let now = OffsetDateTime::now_utc();
400 models::ci::ci_deployment::Entity::update_many()
401 .col_expr(models::ci::ci_deployment::Column::State, Expr::value(state))
402 .col_expr(
403 models::ci::ci_deployment::Column::UpdatedAt,
404 Expr::value(now),
405 )
406 .filter(models::ci::ci_deployment::Column::JobId.eq(job_id))
407 .exec(self.db_write())
408 .await?;
409 Ok(())
410 }
411
412 async fn mark_run_running(&self, run_id: Uuid, runner_id: Uuid) -> CiResult<()> {
413 let now = OffsetDateTime::now_utc();
414 workflow_run::Entity::update_many()
415 .col_expr(
416 workflow_run::Column::State,
417 Expr::value(WorkflowRunState::Running),
418 )
419 .col_expr(workflow_run::Column::RunnerId, Expr::value(runner_id))
420 .col_expr(workflow_run::Column::StartedAt, Expr::value(Some(now)))
421 .col_expr(workflow_run::Column::UpdatedAt, Expr::value(now))
422 .filter(workflow_run::Column::Id.eq(run_id))
423 .filter(workflow_run::Column::State.eq(WorkflowRunState::Queued))
424 .exec(self.db_write())
425 .await?;
426 Ok(())
427 }
428
429 pub async fn renew_lease(&self, job_id: Uuid, runner_id: Uuid) -> CiResult<()> {
430 let now = OffsetDateTime::now_utc();
431 let expires = now + time::Duration::seconds(self.config().ci.lease_secs as i64);
432 let updated = workflow_job::Entity::update_many()
433 .col_expr(
434 workflow_job::Column::LeaseExpiresAt,
435 Expr::value(Some(expires)),
436 )
437 .col_expr(workflow_job::Column::UpdatedAt, Expr::value(now))
438 .filter(workflow_job::Column::Id.eq(job_id))
439 .filter(workflow_job::Column::RunnerId.eq(runner_id))
440 .filter(
441 workflow_job::Column::State
442 .is_in([WorkflowJobState::Claimed, WorkflowJobState::Running]),
443 )
444 .exec(self.db_write())
445 .await?;
446 if updated.rows_affected == 0 {
447 return Err(CiError::NotFound);
448 }
449 Ok(())
450 }
451
452 pub async fn expire_leases(&self) -> CiResult<u64> {
453 let now = OffsetDateTime::now_utc();
454 let max_attempts = self.config().ci.max_attempts;
455 let jobs = workflow_job::Entity::find()
456 .filter(
457 workflow_job::Column::State
458 .is_in([WorkflowJobState::Claimed, WorkflowJobState::Running]),
459 )
460 .filter(workflow_job::Column::LeaseExpiresAt.lt(now))
461 .all(self.db_read())
462 .await?;
463 let mut affected = 0u64;
464 for job in jobs {
465 if job.attempt >= max_attempts {
466 workflow_job::Entity::update_many()
467 .col_expr(
468 workflow_job::Column::State,
469 Expr::value(WorkflowJobState::Failure),
470 )
471 .col_expr(
472 workflow_job::Column::Error,
473 Expr::value(Some("runner lost".to_string())),
474 )
475 .col_expr(workflow_job::Column::FinishedAt, Expr::value(Some(now)))
476 .col_expr(workflow_job::Column::RunnerId, Expr::value(None::<Uuid>))
477 .col_expr(
478 workflow_job::Column::LeaseToken,
479 Expr::value(None::<String>),
480 )
481 .col_expr(
482 workflow_job::Column::LeaseExpiresAt,
483 Expr::value(None::<OffsetDateTime>),
484 )
485 .col_expr(workflow_job::Column::UpdatedAt, Expr::value(now))
486 .filter(workflow_job::Column::Id.eq(job.id))
487 .exec(self.db_write())
488 .await?;
489 self.finish_deployment(job.id, models::ci::ci_deployment::DeploymentState::Failure)
490 .await?;
491 affected += 1;
492 self.record_event(
493 job.run_id,
494 "workflow_job",
495 serde_json::json!({
496 "action": "failed",
497 "job": job.name,
498 "reason": "runner lost",
499 }),
500 )
501 .await;
502 } else {
503 workflow_job::Entity::update_many()
504 .col_expr(
505 workflow_job::Column::State,
506 Expr::value(WorkflowJobState::Queued),
507 )
508 .col_expr(workflow_job::Column::RunnerId, Expr::value(None::<Uuid>))
509 .col_expr(
510 workflow_job::Column::LeaseToken,
511 Expr::value(None::<String>),
512 )
513 .col_expr(
514 workflow_job::Column::LeaseExpiresAt,
515 Expr::value(None::<OffsetDateTime>),
516 )
517 .col_expr(workflow_job::Column::UpdatedAt, Expr::value(now))
518 .filter(workflow_job::Column::Id.eq(job.id))
519 .exec(self.db_write())
520 .await?;
521 self.finish_deployment(
522 job.id,
523 models::ci::ci_deployment::DeploymentState::Cancelled,
524 )
525 .await?;
526 affected += 1;
527 tracing::warn!(
528 job_id = %job.id,
529 run_id = %job.run_id,
530 attempt = job.attempt,
531 "job lease expired; requeued"
532 );
533 }
534 }
535 Ok(affected)
536 }
537
538 pub async fn apply_cancellations(&self) -> CiResult<u64> {
539 let now = OffsetDateTime::now_utc();
540 let grace = self.config().ci.cancel_grace_secs as i64;
541 let runs = workflow_run::Entity::find()
542 .filter(workflow_run::Column::State.eq(WorkflowRunState::CancelRequested))
543 .all(self.db_read())
544 .await?;
545 let mut affected = 0u64;
546 for run in runs {
547 workflow_job::Entity::update_many()
548 .col_expr(
549 workflow_job::Column::State,
550 Expr::value(WorkflowJobState::Cancelled),
551 )
552 .col_expr(workflow_job::Column::FinishedAt, Expr::value(Some(now)))
553 .col_expr(workflow_job::Column::UpdatedAt, Expr::value(now))
554 .filter(workflow_job::Column::RunId.eq(run.id))
555 .filter(
556 workflow_job::Column::State
557 .is_in([WorkflowJobState::Pending, WorkflowJobState::Queued]),
558 )
559 .exec(self.db_write())
560 .await?;
561
562 let stale = now - time::Duration::seconds(grace.max(1));
563 let forced = workflow_job::Entity::update_many()
564 .col_expr(
565 workflow_job::Column::State,
566 Expr::value(WorkflowJobState::Cancelled),
567 )
568 .col_expr(
569 workflow_job::Column::Error,
570 Expr::value(Some("cancelled by janitor".to_string())),
571 )
572 .col_expr(workflow_job::Column::FinishedAt, Expr::value(Some(now)))
573 .col_expr(workflow_job::Column::UpdatedAt, Expr::value(now))
574 .filter(workflow_job::Column::RunId.eq(run.id))
575 .filter(
576 workflow_job::Column::State
577 .is_in([WorkflowJobState::Claimed, WorkflowJobState::Running]),
578 )
579 .filter(workflow_job::Column::UpdatedAt.lt(stale))
580 .exec(self.db_write())
581 .await?;
582 affected += forced.rows_affected;
583
584 let remaining = workflow_job::Entity::find()
585 .filter(workflow_job::Column::RunId.eq(run.id))
586 .all(self.db_read())
587 .await?;
588 if remaining.iter().all(|j| j.state.is_terminal()) {
589 self.finish_run(run.id, WorkflowRunState::Cancelled, None)
590 .await?;
591 affected += 1;
592 }
593 }
594 Ok(affected)
595 }
596
597 pub async fn finalize_runs(&self) -> CiResult<u64> {
598 let runs = workflow_run::Entity::find()
599 .filter(
600 workflow_run::Column::State
601 .is_in([WorkflowRunState::Running, WorkflowRunState::Queued]),
602 )
603 .all(self.db_read())
604 .await?;
605 let mut affected = 0u64;
606 for run in runs {
607 let jobs = workflow_job::Entity::find()
608 .filter(workflow_job::Column::RunId.eq(run.id))
609 .all(self.db_read())
610 .await?;
611 if jobs.is_empty() || !jobs.iter().all(|j| j.state.is_terminal()) {
612 continue;
613 }
614 let failed = jobs.iter().any(|j| {
615 matches!(
616 j.state,
617 WorkflowJobState::Failure
618 | WorkflowJobState::TimedOut
619 | WorkflowJobState::Cancelled
620 )
621 });
622 let state = if failed {
623 WorkflowRunState::Failure
624 } else {
625 WorkflowRunState::Success
626 };
627 self.finish_run(run.id, state, None).await?;
628 affected += 1;
629 }
630 Ok(affected)
631 }
632
633 pub async fn finish_run(
634 &self,
635 run_id: Uuid,
636 state: WorkflowRunState,
637 error: Option<String>,
638 ) -> CiResult<()> {
639 let now = OffsetDateTime::now_utc();
640 let updated = workflow_run::Entity::update_many()
641 .col_expr(workflow_run::Column::State, Expr::value(state))
642 .col_expr(workflow_run::Column::FinishedAt, Expr::value(Some(now)))
643 .col_expr(workflow_run::Column::UpdatedAt, Expr::value(now))
644 .filter(workflow_run::Column::Id.eq(run_id))
645 .filter(workflow_run::Column::State.is_in([
646 WorkflowRunState::Queued,
647 WorkflowRunState::Running,
648 WorkflowRunState::CancelRequested,
649 ]))
650 .exec(self.db_write())
651 .await?;
652 if updated.rows_affected == 0 {
653 return Ok(());
654 }
655 let run = workflow_run::Entity::find_by_id(run_id)
656 .one(self.db_read())
657 .await?
658 .ok_or(CiError::NotFound)?;
659 self.publish_commit_status(&run, state).await?;
660 self.record_event(
661 run_id,
662 "workflow_run",
663 serde_json::json!({
664 "action": "finished",
665 "state": crate::share::states::run_state_str(state),
666 "error": error,
667 }),
668 )
669 .await;
670 tracing::info!(run_id = %run_id, state = ?state, "workflow run finished");
671 Ok(())
672 }
673
674 pub async fn mark_run_cancel_requested(&self, run_id: Uuid) -> CiResult<()> {
675 let now = OffsetDateTime::now_utc();
676 let updated = workflow_run::Entity::update_many()
677 .col_expr(
678 workflow_run::Column::State,
679 Expr::value(WorkflowRunState::CancelRequested),
680 )
681 .col_expr(workflow_run::Column::UpdatedAt, Expr::value(now))
682 .filter(workflow_run::Column::Id.eq(run_id))
683 .filter(
684 workflow_run::Column::State
685 .is_in([WorkflowRunState::Queued, WorkflowRunState::Running]),
686 )
687 .exec(self.db_write())
688 .await?;
689 if updated.rows_affected == 0 {
690 return Err(CiError::Conflict(
691 "only queued or running runs can be cancelled".to_string(),
692 ));
693 }
694 self.record_event(
695 run_id,
696 "workflow_run",
697 serde_json::json!({ "action": "cancel_requested" }),
698 )
699 .await;
700 Ok(())
701 }
702
703 pub async fn cancel_list_for_runner(&self, runner_id: Uuid) -> CiResult<Vec<Uuid>> {
704 let jobs = workflow_job::Entity::find()
705 .filter(workflow_job::Column::RunnerId.eq(runner_id))
706 .filter(
707 workflow_job::Column::State
708 .is_in([WorkflowJobState::Claimed, WorkflowJobState::Running]),
709 )
710 .all(self.db_read())
711 .await?;
712 let mut out = Vec::new();
713 for job in jobs {
714 let run = workflow_run::Entity::find_by_id(job.run_id)
715 .one(self.db_read())
716 .await?;
717 if let Some(run) = run
718 && run.state == WorkflowRunState::CancelRequested
719 {
720 out.push(job.id);
721 }
722 }
723 Ok(out)
724 }
725
726 pub async fn start_job(&self, job_id: Uuid, runner_id: Uuid) -> CiResult<Uuid> {
727 let now = OffsetDateTime::now_utc();
728 let updated = workflow_job::Entity::update_many()
729 .col_expr(
730 workflow_job::Column::State,
731 Expr::value(WorkflowJobState::Running),
732 )
733 .col_expr(workflow_job::Column::StartedAt, Expr::value(Some(now)))
734 .col_expr(workflow_job::Column::UpdatedAt, Expr::value(now))
735 .filter(workflow_job::Column::Id.eq(job_id))
736 .filter(workflow_job::Column::RunnerId.eq(runner_id))
737 .filter(
738 workflow_job::Column::State
739 .is_in([WorkflowJobState::Claimed, WorkflowJobState::Running]),
740 )
741 .exec(self.db_write())
742 .await?;
743 if updated.rows_affected == 0 {
744 return Err(CiError::NotFound);
745 }
746 let job = workflow_job::Entity::find_by_id(job_id)
747 .one(self.db_read())
748 .await?
749 .ok_or(CiError::NotFound)?;
750 Ok(job.run_id)
751 }
752
753 pub async fn report_step(
754 &self,
755 job_id: Uuid,
756 runner_id: Uuid,
757 index: i32,
758 report: &ci_protocol::runner::StepReport,
759 ) -> CiResult<()> {
760 self.ensure_job_owned(job_id, runner_id).await?;
761 let now = OffsetDateTime::now_utc();
762 let (state, exit_code, started_at, finished_at) = match report.state {
763 StepReportState::Started => (
764 WorkflowStepState::Running,
765 None,
766 report.started_at.or(Some(now)),
767 None,
768 ),
769 StepReportState::Finished => {
770 let exit = report.exit_code.unwrap_or(0);
771 let state = if exit == 0 {
772 WorkflowStepState::Success
773 } else {
774 WorkflowStepState::Failure
775 };
776 (
777 state,
778 Some(exit),
779 report.started_at,
780 report.finished_at.or(Some(now)),
781 )
782 }
783 };
784 let step = workflow_step::Entity::find()
785 .filter(workflow_step::Column::JobId.eq(job_id))
786 .filter(workflow_step::Column::StepIndex.eq(index))
787 .one(self.db_read())
788 .await?
789 .ok_or(CiError::NotFound)?;
790 let mut am: workflow_step::ActiveModel = step.into();
791 am.state = Set(state);
792 am.exit_code = Set(exit_code);
793 if let Some(name) = &report.name {
794 am.name = Set(name.clone());
795 }
796 if let Some(shell) = &report.shell {
797 am.shell = Set(Some(shell.clone()));
798 }
799 if let Some(workdir) = &report.working_directory {
800 am.working_directory = Set(Some(workdir.clone()));
801 }
802 if let Some(script) = &report.script {
803 am.script = Set(script.clone());
804 }
805 if let Some(started) = started_at {
806 am.started_at = Set(Some(started));
807 }
808 if let Some(finished) = finished_at {
809 am.finished_at = Set(Some(finished));
810 }
811 am.updated_at = Set(now);
812 am.update(self.db_write()).await?;
813 Ok(())
814 }
815
816 pub async fn ensure_job_owned(&self, job_id: Uuid, runner_id: Uuid) -> CiResult<()> {
817 let job = workflow_job::Entity::find_by_id(job_id)
818 .one(self.db_read())
819 .await?
820 .ok_or(CiError::NotFound)?;
821 if job.runner_id != Some(runner_id) {
822 return Err(CiError::Forbidden);
823 }
824 Ok(())
825 }
826
827 pub async fn finish_job(
828 &self,
829 job_id: Uuid,
830 runner_id: Uuid,
831 finish: &ci_protocol::runner::JobFinishRequest,
832 ) -> CiResult<Uuid> {
833 let job = workflow_job::Entity::find_by_id(job_id)
834 .one(self.db_read())
835 .await?
836 .ok_or(CiError::NotFound)?;
837 if job.runner_id != Some(runner_id) {
838 return Err(CiError::Forbidden);
839 }
840 if job.state.is_terminal() {
841 return Ok(job.run_id);
842 }
843 let now = OffsetDateTime::now_utc();
844 let state = match finish.state {
845 JobFinishState::Success => WorkflowJobState::Success,
846 JobFinishState::Failure => WorkflowJobState::Failure,
847 JobFinishState::TimedOut => WorkflowJobState::TimedOut,
848 JobFinishState::Cancelled => WorkflowJobState::Cancelled,
849 };
850 let updated = workflow_job::Entity::update_many()
851 .col_expr(workflow_job::Column::State, Expr::value(state))
852 .col_expr(
853 workflow_job::Column::ExitCode,
854 Expr::value(finish.exit_code),
855 )
856 .col_expr(
857 workflow_job::Column::Error,
858 Expr::value(finish.error.clone()),
859 )
860 .col_expr(workflow_job::Column::FinishedAt, Expr::value(Some(now)))
861 .col_expr(
862 workflow_job::Column::LeaseToken,
863 Expr::value(None::<String>),
864 )
865 .col_expr(
866 workflow_job::Column::LeaseExpiresAt,
867 Expr::value(None::<OffsetDateTime>),
868 )
869 .col_expr(workflow_job::Column::UpdatedAt, Expr::value(now))
870 .filter(workflow_job::Column::Id.eq(job_id))
871 .filter(workflow_job::Column::RunnerId.eq(runner_id))
872 .exec(self.db_write())
873 .await?;
874 if updated.rows_affected == 0 {
875 return Ok(job.run_id);
876 }
877 if state != WorkflowJobState::Success {
878 workflow_step::Entity::update_many()
879 .col_expr(
880 workflow_step::Column::State,
881 Expr::value(WorkflowStepState::Cancelled),
882 )
883 .filter(workflow_step::Column::JobId.eq(job_id))
884 .filter(
885 workflow_step::Column::State
886 .is_in([WorkflowStepState::Pending, WorkflowStepState::Running]),
887 )
888 .exec(self.db_write())
889 .await?;
890 }
891 let deployment_state = match state {
892 WorkflowJobState::Success => models::ci::ci_deployment::DeploymentState::Success,
893 WorkflowJobState::Cancelled => models::ci::ci_deployment::DeploymentState::Cancelled,
894 _ => models::ci::ci_deployment::DeploymentState::Failure,
895 };
896 self.finish_deployment(job.id, deployment_state).await?;
897 self.record_event(
898 job.run_id,
899 "workflow_job",
900 serde_json::json!({
901 "action": "finished",
902 "job": job.name,
903 "state": state.as_str(),
904 "exit_code": finish.exit_code,
905 }),
906 )
907 .await;
908 Ok(job.run_id)
909 }
910
911 pub async fn record_event(&self, run_id: Uuid, event_type: &str, payload: serde_json::Value) {
912 let run = match workflow_run::Entity::find_by_id(run_id)
913 .one(self.db_read())
914 .await
915 {
916 Ok(Some(run)) => run,
917 Ok(None) => return,
918 Err(error) => {
919 tracing::warn!(%error, "ci event run lookup failed");
920 return;
921 }
922 };
923 if let Err(error) =
924 crate::outbound::enqueue_for_repo(self, run.repo_id, event_type, &payload).await
925 {
926 tracing::warn!(%error, "outbound webhook enqueue failed");
927 }
928 }
929
930 pub async fn repo_by_id(&self, repo_id: Uuid) -> CiResult<models::repo::repos::Model> {
931 models::repo::repos::Entity::find_by_id(repo_id)
932 .one(self.db_read())
933 .await?
934 .ok_or(CiError::NotFound)
935 }
936
937 async fn publish_commit_status(
938 &self,
939 run: &workflow_run::Model,
940 state: WorkflowRunState,
941 ) -> CiResult<()> {
942 let Some(creator) = run.trigger_user_id else {
943 tracing::warn!(run_id = %run.id, "run has no trigger user; skipping commit status");
944 return Ok(());
945 };
946 let repo = self.repo_by_id(run.repo_id).await?;
947 let space = self.space_of_repo(&repo).await?;
948 let git = git::service::GitService::new(self.state.clone());
949 let dto_state = match state {
950 WorkflowRunState::Success => git::service::commits::CommitStatusDtoState::Success,
951 WorkflowRunState::Failure => git::service::commits::CommitStatusDtoState::Failure,
952 WorkflowRunState::Cancelled => git::service::commits::CommitStatusDtoState::Error,
953 _ => git::service::commits::CommitStatusDtoState::Pending,
954 };
955 let description = match state {
956 WorkflowRunState::Success => "workflow succeeded",
957 WorkflowRunState::Failure => "workflow failed",
958 WorkflowRunState::Cancelled => "workflow cancelled",
959 _ => "workflow running",
960 };
961 let base_url = self.config().git.base_url.trim_end_matches('/').to_string();
962 let target_url = format!(
963 "{base_url}/app/{}/repo/{}?tab=pipelines&run={}",
964 space.lower_name, repo.name, run.id
965 );
966 if let Err(error) = git
967 .set_commit_status(
968 space.lower_name.clone(),
969 repo.name.clone(),
970 creator,
971 git::service::commits::SetCommitStatusCmd {
972 sha: run.commit_sha.clone(),
973 state: dto_state,
974 context: format!("ci/{}", run.workflow_name),
975 description: Some(description.to_string()),
976 target_url: Some(target_url),
977 },
978 )
979 .await
980 {
981 tracing::warn!(run_id = %run.id, error = %error, "commit status write failed");
982 }
983 Ok(())
984 }
985}
Added lib/ci/src/secrets.rs View lib/ci/src/secrets.rs
@@ -0,0 +1,147 @@
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}
Added lib/ci/src/service.rs View lib/ci/src/service.rs
@@ -0,0 +1,128 @@
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}
Added lib/ci/src/storage.rs View lib/ci/src/storage.rs
@@ -0,0 +1,252 @@
1use models::ci::workflow_artifact::{self, ArtifactState};
2use models::ci::workflow_run;
3use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set};
4use time::OffsetDateTime;
5use uuid::Uuid;
6
7use crate::errors::{CiError, CiResult};
8use crate::service::CiService;
9
10impl CiService {
11 pub fn log_object_key(run_id: Uuid, job_id: Uuid, seq: i32) -> String {
12 format!("ci/logs/{run_id}/{job_id}/{seq:06}.log")
13 }
14
15 pub fn artifact_object_key(run_id: Uuid, job_id: Uuid, artifact_id: Uuid) -> String {
16 format!("ci/artifacts/{run_id}/{job_id}/{artifact_id}")
17 }
18
19 pub async fn init_artifact(
20 &self,
21 job_id: Uuid,
22 runner_id: Uuid,
23 name: &str,
24 size: i64,
25 sha256: Option<String>,
26 ) -> CiResult<(Uuid, String)> {
27 self.ensure_job_owned(job_id, runner_id).await?;
28 if size < 0 {
29 return Err(CiError::InvalidArgument(
30 "artifact size must not be negative".to_string(),
31 ));
32 }
33 if size as u64 > self.config().ci.artifact_max_bytes {
34 return Err(CiError::InvalidArgument(
35 "artifact exceeds the configured size limit".to_string(),
36 ));
37 }
38 let job = models::ci::workflow_job::Entity::find_by_id(job_id)
39 .one(self.db_read())
40 .await?
41 .ok_or(CiError::NotFound)?;
42 let artifact_id = Uuid::now_v7();
43 let key = Self::artifact_object_key(job.run_id, job_id, artifact_id);
44 let now = OffsetDateTime::now_utc();
45 let expires_at =
46 now + time::Duration::days(self.config().ci.artifact_retention_days.max(1));
47 workflow_artifact::ActiveModel {
48 id: Set(artifact_id),
49 run_id: Set(job.run_id),
50 job_id: Set(job_id),
51 name: Set(name.to_string()),
52 object_key: Set(key.clone()),
53 size: Set(size),
54 sha256: Set(sha256),
55 state: Set(ArtifactState::Uploading),
56 expires_at: Set(Some(expires_at)),
57 created_at: Set(now),
58 updated_at: Set(now),
59 }
60 .insert(self.db_write())
61 .await?;
62 let url = self
63 .storage()
64 .client()
65 .presign_put(&key, 3600)
66 .map_err(CiError::from)?;
67 Ok((artifact_id, url))
68 }
69
70 pub async fn complete_artifact(
71 &self,
72 job_id: Uuid,
73 runner_id: Uuid,
74 artifact_id: Uuid,
75 size: i64,
76 sha256: Option<String>,
77 ) -> CiResult<()> {
78 self.ensure_job_owned(job_id, runner_id).await?;
79 let row = workflow_artifact::Entity::find_by_id(artifact_id)
80 .one(self.db_read())
81 .await?
82 .ok_or(CiError::NotFound)?;
83 if row.job_id != job_id {
84 return Err(CiError::NotFound);
85 }
86 let exists = self
87 .storage()
88 .client()
89 .exists(&row.object_key)
90 .await
91 .map_err(CiError::from)?;
92 if !exists {
93 return Err(CiError::InvalidArgument(
94 "artifact object was not uploaded".to_string(),
95 ));
96 }
97 let mut am: workflow_artifact::ActiveModel = row.into();
98 am.size = Set(size);
99 if sha256.is_some() {
100 am.sha256 = Set(sha256);
101 }
102 am.state = Set(ArtifactState::Ready);
103 am.updated_at = Set(OffsetDateTime::now_utc());
104 am.update(self.db_write()).await?;
105 Ok(())
106 }
107
108 pub async fn artifact_download_url(&self, artifact_id: Uuid) -> CiResult<String> {
109 let row = workflow_artifact::Entity::find_by_id(artifact_id)
110 .one(self.db_read())
111 .await?
112 .ok_or(CiError::NotFound)?;
113 if row.state != ArtifactState::Ready {
114 return Err(CiError::InvalidArgument(
115 "artifact is not ready".to_string(),
116 ));
117 }
118 self.storage()
119 .client()
120 .presign_get(&row.object_key, 900)
121 .map_err(CiError::from)
122 }
123
124 pub async fn artifacts_for_run(&self, run_id: Uuid) -> CiResult<Vec<workflow_artifact::Model>> {
125 Ok(workflow_artifact::Entity::find()
126 .filter(workflow_artifact::Column::RunId.eq(run_id))
127 .all(self.db_read())
128 .await?)
129 }
130
131 pub async fn needs_artifacts(
132 &self,
133 run_id: Uuid,
134 job_id: Uuid,
135 ) -> CiResult<Vec<ci_protocol::runner::NeedsArtifactDto>> {
136 let job = models::ci::workflow_job::Entity::find_by_id(job_id)
137 .one(self.db_read())
138 .await?
139 .ok_or(CiError::NotFound)?;
140 let needs: Vec<String> = serde_json::from_value(job.needs.clone()).unwrap_or_default();
141 if needs.is_empty() {
142 return Ok(Vec::new());
143 }
144 let jobs = models::ci::workflow_job::Entity::find()
145 .filter(models::ci::workflow_job::Column::RunId.eq(run_id))
146 .all(self.db_read())
147 .await?;
148 let mut out = Vec::new();
149 for need in needs {
150 let Some(need_job) = jobs.iter().find(|j| j.job_key == need) else {
151 continue;
152 };
153 let artifacts = self.artifacts_for_run(run_id).await?;
154 for artifact in artifacts
155 .into_iter()
156 .filter(|a| a.job_id == need_job.id && a.state == ArtifactState::Ready)
157 {
158 let download_url = self
159 .storage()
160 .client()
161 .presign_get(&artifact.object_key, 3600)
162 .map_err(CiError::from)?;
163 out.push(ci_protocol::runner::NeedsArtifactDto {
164 run_id,
165 job_id: need_job.id,
166 name: artifact.name,
167 download_url,
168 size: artifact.size,
169 sha256: artifact.sha256,
170 });
171 }
172 }
173 Ok(out)
174 }
175
176 pub async fn prune_expired(&self) -> CiResult<u64> {
177 use models::ci::ci_log_chunk;
178 use models::ci::workflow_artifact as artifact_entity;
179
180 let now = OffsetDateTime::now_utc();
181 let mut removed = 0u64;
182
183 let log_cutoff = now - time::Duration::days(self.config().ci.log_retention_days.max(0));
184 let logs = ci_log_chunk::Entity::find()
185 .filter(ci_log_chunk::Column::CreatedAt.lt(log_cutoff))
186 .limit(500)
187 .all(self.db_read())
188 .await?;
189 for chunk in logs {
190 let _ = self.storage().client().delete(&chunk.object_key).await;
191 let _ = ci_log_chunk::Entity::delete_by_id(chunk.id)
192 .exec(self.db_write())
193 .await;
194 removed += 1;
195 }
196
197 let artifact_cutoff =
198 now - time::Duration::days(self.config().ci.artifact_retention_days.max(0));
199 let artifacts = artifact_entity::Entity::find()
200 .filter(artifact_entity::Column::CreatedAt.lt(artifact_cutoff))
201 .limit(500)
202 .all(self.db_read())
203 .await?;
204 for artifact in artifacts {
205 let _ = self.storage().client().delete(&artifact.object_key).await;
206 let _ = artifact_entity::Entity::delete_by_id(artifact.id)
207 .exec(self.db_write())
208 .await;
209 removed += 1;
210 }
211
212 let run_cutoff = now - time::Duration::days(self.config().ci.run_retention_days.max(0));
213 let runs = workflow_run::Entity::find()
214 .filter(workflow_run::Column::CreatedAt.lt(run_cutoff))
215 .limit(200)
216 .all(self.db_read())
217 .await?;
218 for run in runs {
219 let job_ids: Vec<Uuid> = models::ci::workflow_job::Entity::find()
220 .filter(models::ci::workflow_job::Column::RunId.eq(run.id))
221 .all(self.db_read())
222 .await
223 .unwrap_or_default()
224 .into_iter()
225 .map(|j| j.id)
226 .collect();
227 for job_id in &job_ids {
228 let _ = models::ci::workflow_step::Entity::delete_many()
229 .filter(models::ci::workflow_step::Column::JobId.eq(*job_id))
230 .exec(self.db_write())
231 .await;
232 let _ = models::ci::ci_log_chunk::Entity::delete_many()
233 .filter(models::ci::ci_log_chunk::Column::JobId.eq(*job_id))
234 .exec(self.db_write())
235 .await;
236 }
237 let _ = models::ci::workflow_job::Entity::delete_many()
238 .filter(models::ci::workflow_job::Column::RunId.eq(run.id))
239 .exec(self.db_write())
240 .await;
241 let _ = models::ci::ci_deployment::Entity::delete_many()
242 .filter(models::ci::ci_deployment::Column::RunId.eq(run.id))
243 .exec(self.db_write())
244 .await;
245 let _ = workflow_run::Entity::delete_by_id(run.id)
246 .exec(self.db_write())
247 .await;
248 removed += 1;
249 }
250 Ok(removed)
251 }
252}
Added lib/ci/src/triggers.rs View lib/ci/src/triggers.rs
@@ -0,0 +1,322 @@
1use models::ci::workflow_job;
2use models::ci::workflow_run::{self, WorkflowEventKind, WorkflowRunState};
3use models::ci::workflow_step;
4use models::repo::repos;
5use models::space::spaces;
6use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect};
7use time::OffsetDateTime;
8use uuid::Uuid;
9
10use crate::errors::{CiError, CiResult};
11use crate::runs::RunSeed;
12use crate::service::CiService;
13
14#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
15pub struct RunDto {
16 pub id: Uuid,
17 pub repo_id: Uuid,
18 pub workflow_file: String,
19 pub workflow_name: String,
20 pub event: String,
21 pub commit_sha: String,
22 pub git_ref: String,
23 pub branch: Option<String>,
24 pub tag: Option<String>,
25 pub base_sha: Option<String>,
26 pub base_ref: Option<String>,
27 pub pr_number: Option<i64>,
28 pub state: String,
29 pub trigger_user_id: Option<Uuid>,
30 pub started_at: Option<OffsetDateTime>,
31 pub finished_at: Option<OffsetDateTime>,
32 pub created_at: OffsetDateTime,
33}
34
35#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
36pub struct StepDto {
37 pub index: i32,
38 pub name: String,
39 pub state: String,
40 pub exit_code: Option<i32>,
41 pub started_at: Option<OffsetDateTime>,
42 pub finished_at: Option<OffsetDateTime>,
43}
44
45#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
46pub struct JobDto {
47 pub id: Uuid,
48 pub name: String,
49 pub state: String,
50 pub runs_on: Vec<String>,
51 pub environment: Option<String>,
52 pub needs: Vec<String>,
53 pub runner_id: Option<Uuid>,
54 pub attempt: i32,
55 pub exit_code: Option<i32>,
56 pub error: Option<String>,
57 pub started_at: Option<OffsetDateTime>,
58 pub finished_at: Option<OffsetDateTime>,
59 pub steps: Vec<StepDto>,
60}
61
62#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
63pub struct RunDetailDto {
64 pub run: RunDto,
65 pub jobs: Vec<JobDto>,
66 pub artifacts: Vec<ArtifactDto>,
67}
68
69#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
70pub struct ArtifactDto {
71 pub id: Uuid,
72 pub job_id: Uuid,
73 pub name: String,
74 pub size: i64,
75 pub sha256: Option<String>,
76 pub state: String,
77 pub created_at: OffsetDateTime,
78}
79
80#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
81pub struct ListRunsQuery {
82 pub workflow: Option<String>,
83 pub branch: Option<String>,
84 pub commit: Option<String>,
85 pub state: Option<WorkflowRunState>,
86 pub event: Option<String>,
87 pub limit: Option<u64>,
88 pub offset: Option<u64>,
89}
90
91#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)]
92pub struct TriggerRunCmd {
93 pub workflow: String,
94 pub git_ref: String,
95}
96
97#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
98pub struct RunPageDto {
99 pub runs: Vec<RunDto>,
100 pub total: u64,
101 pub limit: u64,
102 pub offset: u64,
103}
104
105pub fn run_state_str(state: WorkflowRunState) -> String {
106 crate::share::states::run_state_str(state).to_string()
107}
108
109pub fn job_state_str(state: workflow_job::WorkflowJobState) -> String {
110 state.as_str().to_string()
111}
112
113pub fn step_state_str(state: workflow_step::WorkflowStepState) -> String {
114 use workflow_step::WorkflowStepState as S;
115 match state {
116 S::Pending => "pending",
117 S::Running => "running",
118 S::Success => "success",
119 S::Failure => "failure",
120 S::Skipped => "skipped",
121 S::Cancelled => "cancelled",
122 }
123 .to_string()
124}
125
126impl From<workflow_run::Model> for RunDto {
127 fn from(m: workflow_run::Model) -> Self {
128 Self {
129 id: m.id,
130 repo_id: m.repo_id,
131 workflow_file: m.workflow_file,
132 workflow_name: m.workflow_name,
133 event: m.event.as_str().to_string(),
134 commit_sha: m.commit_sha,
135 git_ref: m.git_ref,
136 branch: m.branch,
137 tag: m.tag,
138 base_sha: m.base_sha,
139 base_ref: m.base_ref,
140 pr_number: m.pr_number,
141 state: run_state_str(m.state),
142 trigger_user_id: m.trigger_user_id,
143 started_at: m.started_at,
144 finished_at: m.finished_at,
145 created_at: m.created_at,
146 }
147 }
148}
149
150impl CiService {
151 pub async fn list_runs(
152 &self,
153 repo: &repos::Model,
154 query: &ListRunsQuery,
155 ) -> CiResult<RunPageDto> {
156 let limit = query.limit.unwrap_or(50).clamp(1, 100);
157 let offset = query.offset.unwrap_or(0).min(50_000);
158 let mut q = workflow_run::Entity::find()
159 .filter(workflow_run::Column::RepoId.eq(repo.id))
160 .order_by_desc(workflow_run::Column::CreatedAt);
161 if let Some(workflow) = query.workflow.clone().filter(|w| !w.is_empty()) {
162 q = q.filter(workflow_run::Column::WorkflowFile.eq(workflow));
163 }
164 if let Some(branch) = query.branch.clone().filter(|b| !b.is_empty()) {
165 q = q.filter(workflow_run::Column::Branch.eq(branch));
166 }
167 if let Some(commit) = query.commit.clone().filter(|c| !c.is_empty()) {
168 q = q.filter(workflow_run::Column::CommitSha.eq(commit));
169 }
170 if let Some(state) = query.state {
171 q = q.filter(workflow_run::Column::State.eq(state));
172 }
173 if let Some(event) = query.event.as_deref() {
174 let kind = match event {
175 "push" => Some(WorkflowEventKind::Push),
176 "pull_request" => Some(WorkflowEventKind::PullRequest),
177 "workflow_dispatch" => Some(WorkflowEventKind::WorkflowDispatch),
178 _ => None,
179 };
180 if let Some(kind) = kind {
181 q = q.filter(workflow_run::Column::Event.eq(kind));
182 }
183 }
184 let total = q.clone().count(self.db_read()).await?;
185 let rows = q.limit(limit).offset(offset).all(self.db_read()).await?;
186 Ok(RunPageDto {
187 runs: rows.into_iter().map(RunDto::from).collect(),
188 total,
189 limit,
190 offset,
191 })
192 }
193
194 pub async fn run_detail(&self, run_id: Uuid) -> CiResult<RunDetailDto> {
195 let run = workflow_run::Entity::find_by_id(run_id)
196 .one(self.db_read())
197 .await?
198 .ok_or(CiError::NotFound)?;
199 let jobs = workflow_job::Entity::find()
200 .filter(workflow_job::Column::RunId.eq(run_id))
201 .order_by_asc(workflow_job::Column::CreatedAt)
202 .all(self.db_read())
203 .await?;
204 let mut job_dtos = Vec::with_capacity(jobs.len());
205 for job in &jobs {
206 let steps = workflow_step::Entity::find()
207 .filter(workflow_step::Column::JobId.eq(job.id))
208 .order_by_asc(workflow_step::Column::StepIndex)
209 .all(self.db_read())
210 .await?;
211 job_dtos.push(JobDto {
212 id: job.id,
213 name: job.name.clone(),
214 state: job_state_str(job.state),
215 runs_on: serde_json::from_value(job.runs_on.clone()).unwrap_or_default(),
216 environment: job.environment.clone(),
217 needs: serde_json::from_value(job.needs.clone()).unwrap_or_default(),
218 runner_id: job.runner_id,
219 attempt: job.attempt,
220 exit_code: job.exit_code,
221 error: job.error.clone(),
222 started_at: job.started_at,
223 finished_at: job.finished_at,
224 steps: steps
225 .into_iter()
226 .map(|step| StepDto {
227 index: step.step_index,
228 name: step.name,
229 state: step_state_str(step.state),
230 exit_code: step.exit_code,
231 started_at: step.started_at,
232 finished_at: step.finished_at,
233 })
234 .collect(),
235 });
236 }
237 let artifacts = self
238 .artifacts_for_run(run_id)
239 .await?
240 .into_iter()
241 .map(|a| ArtifactDto {
242 id: a.id,
243 job_id: a.job_id,
244 name: a.name,
245 size: a.size,
246 sha256: a.sha256,
247 state: match a.state {
248 models::ci::workflow_artifact::ArtifactState::Pending => "pending",
249 models::ci::workflow_artifact::ArtifactState::Uploading => "uploading",
250 models::ci::workflow_artifact::ArtifactState::Ready => "ready",
251 models::ci::workflow_artifact::ArtifactState::Failed => "failed",
252 }
253 .to_string(),
254 created_at: a.created_at,
255 })
256 .collect();
257 Ok(RunDetailDto {
258 run: RunDto::from(run),
259 jobs: job_dtos,
260 artifacts,
261 })
262 }
263
264 /// Manual `workflow_dispatch` trigger.
265 pub async fn trigger_run(
266 &self,
267 space: &spaces::Model,
268 repo: &repos::Model,
269 caller: Uuid,
270 cmd: &TriggerRunCmd,
271 ) -> CiResult<Uuid> {
272 self.ensure_enabled()?;
273 let git = git::service::GitService::new(self.state.clone());
274 let sha = git
275 .rev_parse(
276 space.lower_name.clone(),
277 repo.name.clone(),
278 cmd.git_ref.clone(),
279 )
280 .await
281 .map_err(CiError::from)?;
282 let workflow = self
283 .load_workflow_by_file(space, &repo.name, &sha, &cmd.workflow)
284 .await?;
285 if workflow.definition.on.workflow_dispatch.is_none() {
286 return Err(CiError::InvalidArgument(
287 "workflow does not declare `workflow_dispatch`".to_string(),
288 ));
289 }
290 let (branch, tag) = classify_ref(&cmd.git_ref);
291 let seed = RunSeed {
292 event: WorkflowEventKind::WorkflowDispatch,
293 event_payload: serde_json::json!({
294 "ref": cmd.git_ref,
295 "sha": sha,
296 "actor_id": caller,
297 }),
298 commit_sha: sha,
299 git_ref: cmd.git_ref.clone(),
300 branch,
301 tag,
302 base_sha: None,
303 base_ref: None,
304 pr_number: None,
305 trigger_user_id: Some(caller),
306 };
307 self.create_run(repo, space, &seed, &workflow).await
308 }
309}
310
311#[must_use]
312pub fn classify_ref(git_ref: &str) -> (Option<String>, Option<String>) {
313 if let Some(tag) = git_ref.strip_prefix("refs/tags/") {
314 (None, Some(tag.to_string()))
315 } else if let Some(branch) = git_ref.strip_prefix("refs/heads/") {
316 (Some(branch.to_string()), None)
317 } else if git_ref.starts_with("refs/") || git_ref.contains('/') {
318 (None, None)
319 } else {
320 (Some(git_ref.to_string()), None)
321 }
322}
Added lib/ci/src/workflows.rs View lib/ci/src/workflows.rs
@@ -0,0 +1,320 @@
1use ci_protocol::dsl::{
2 PullRequestTrigger, PushTrigger, TriggerSummary, WORKFLOW_DIR, WorkflowDefinition,
3 WorkflowSummary, parse_workflow_str,
4};
5use git::service::GitService;
6use models::space::spaces;
7
8use crate::errors::{CiError, CiResult};
9use crate::service::CiService;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct LoadedWorkflow {
13 pub file: String,
14 pub name: String,
15 pub definition: WorkflowDefinition,
16}
17
18const MAX_WORKFLOW_BYTES: u64 = 256 * 1024;
19
20impl CiService {
21 fn git_service(&self) -> GitService {
22 GitService::new(self.state.clone())
23 }
24
25 /// Lists the `.workflows/` directory at `revision`, returning every
26 /// YAML file with its parse outcome.
27 pub async fn list_workflows(
28 &self,
29 space: &spaces::Model,
30 repo_name: &str,
31 revision: &str,
32 ) -> CiResult<Vec<WorkflowSummary>> {
33 let entries = self.workflow_entries(space, repo_name, revision).await?;
34 let mut out = Vec::with_capacity(entries.len());
35 for entry in entries {
36 let source = match self
37 .read_workflow_file(space, repo_name, revision, &entry)
38 .await
39 {
40 Ok(source) => source,
41 Err(error) => {
42 out.push(WorkflowSummary {
43 file: entry.clone(),
44 name: entry.clone(),
45 valid: false,
46 errors: vec![error.to_string()],
47 triggers: TriggerSummary::default(),
48 });
49 continue;
50 }
51 };
52 match parse_workflow_str(&source) {
53 Ok(definition) => out.push(WorkflowSummary {
54 file: entry.clone(),
55 name: definition.name.clone().unwrap_or_else(|| file_stem(&entry)),
56 valid: true,
57 errors: Vec::new(),
58 triggers: TriggerSummary::from_triggers(&definition.on),
59 }),
60 Err(error) => out.push(WorkflowSummary {
61 file: entry.clone(),
62 name: file_stem(&entry),
63 valid: false,
64 errors: vec![error.to_string()],
65 triggers: TriggerSummary::default(),
66 }),
67 }
68 }
69 Ok(out)
70 }
71
72 pub async fn load_valid_workflows(
73 &self,
74 space: &spaces::Model,
75 repo_name: &str,
76 revision: &str,
77 ) -> CiResult<Vec<LoadedWorkflow>> {
78 let entries = self.workflow_entries(space, repo_name, revision).await?;
79 let mut out = Vec::new();
80 for entry in entries {
81 let source = match self
82 .read_workflow_file(space, repo_name, revision, &entry)
83 .await
84 {
85 Ok(source) => source,
86 Err(error) => {
87 tracing::warn!(file = %entry, error = %error, "workflow read failed; skipping");
88 continue;
89 }
90 };
91 match parse_workflow_str(&source) {
92 Ok(definition) => out.push(LoadedWorkflow {
93 name: definition.name.clone().unwrap_or_else(|| file_stem(&entry)),
94 file: entry,
95 definition,
96 }),
97 Err(error) => {
98 tracing::warn!(file = %entry, error = %error, "workflow parse failed; skipping");
99 }
100 }
101 }
102 Ok(out)
103 }
104
105 pub async fn load_workflow_by_file(
106 &self,
107 space: &spaces::Model,
108 repo_name: &str,
109 revision: &str,
110 file: &str,
111 ) -> CiResult<LoadedWorkflow> {
112 let normalized = normalize_workflow_file(file)?;
113 let source = self
114 .read_workflow_file(space, repo_name, revision, &normalized)
115 .await?;
116 let definition = parse_workflow_str(&source)
117 .map_err(|e| CiError::InvalidArgument(format!("workflow `{normalized}`: {e}")))?;
118 Ok(LoadedWorkflow {
119 name: definition
120 .name
121 .clone()
122 .unwrap_or_else(|| file_stem(&normalized)),
123 file: normalized,
124 definition,
125 })
126 }
127
128 async fn workflow_entries(
129 &self,
130 space: &spaces::Model,
131 repo_name: &str,
132 revision: &str,
133 ) -> CiResult<Vec<String>> {
134 let service = self.git_service();
135 let entries = service
136 .tree_read(
137 space.lower_name.clone(),
138 repo_name.to_string(),
139 revision.to_string(),
140 std::path::PathBuf::from(WORKFLOW_DIR),
141 )
142 .await;
143 let entries = match entries {
144 Ok(entries) => entries,
145 Err(git::share::errors::GitError::NotFound) => return Ok(Vec::new()),
146 Err(error) => return Err(error.into()),
147 };
148 // `ls-tree <rev> -- .workflows/` returns repo-relative names
149 // (`.workflows/ci.yml`), while `ls-tree <rev>:.workflows` returns bare
150 // names; normalize both to the bare file name the rest of this module
151 // expects.
152 let prefix = format!("{WORKFLOW_DIR}/");
153 let mut out: Vec<String> = entries
154 .into_iter()
155 .filter(|entry| {
156 entry.mode == gitbaby::commit::types::TreeEntryMode::Blob
157 || entry.mode == gitbaby::commit::types::TreeEntryMode::Exec
158 })
159 .map(|entry| {
160 entry
161 .name
162 .strip_prefix(prefix.as_str())
163 .unwrap_or(entry.name.as_str())
164 .to_string()
165 })
166 .filter(|name| {
167 std::path::Path::new(name).extension().is_some_and(|ext| {
168 ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml")
169 })
170 })
171 .collect();
172 out.sort();
173 out.dedup();
174 Ok(out)
175 }
176
177 async fn read_workflow_file(
178 &self,
179 space: &spaces::Model,
180 repo_name: &str,
181 revision: &str,
182 file: &str,
183 ) -> CiResult<String> {
184 let service = self.git_service();
185 let path = format!("{WORKFLOW_DIR}/{file}");
186 let entry = service
187 .tree_resolve_path(
188 space.lower_name.clone(),
189 repo_name.to_string(),
190 revision.to_string(),
191 path.clone(),
192 )
193 .await
194 .map_err(CiError::from)?;
195 if entry.mode != gitbaby::commit::types::TreeEntryMode::Blob
196 && entry.mode != gitbaby::commit::types::TreeEntryMode::Exec
197 {
198 return Err(CiError::InvalidArgument(format!(
199 "`{path}` is not a regular file"
200 )));
201 }
202 let blob = service
203 .blob_content(
204 space.lower_name.clone(),
205 repo_name.to_string(),
206 entry.oid.to_string(),
207 )
208 .await
209 .map_err(CiError::from)?;
210 if blob.truncated || blob.size as u64 > MAX_WORKFLOW_BYTES {
211 return Err(CiError::InvalidArgument(format!(
212 "`{path}` exceeds the {MAX_WORKFLOW_BYTES} byte workflow limit"
213 )));
214 }
215 String::from_utf8(blob.data)
216 .map_err(|_| CiError::InvalidArgument(format!("`{path}` is not valid UTF-8")))
217 }
218}
219
220pub fn normalize_workflow_file(file: &str) -> CiResult<String> {
221 let trimmed = file.trim();
222 let name = trimmed
223 .strip_prefix(&format!("{WORKFLOW_DIR}/"))
224 .unwrap_or(trimmed);
225 if name.is_empty()
226 || name.contains('/')
227 || name.contains('\\')
228 || name.contains("..")
229 || name.len() > 200
230 {
231 return Err(CiError::InvalidArgument(
232 "workflow file must be a bare file name under .workflows/".to_string(),
233 ));
234 }
235 let ext_ok = std::path::Path::new(name)
236 .extension()
237 .is_some_and(|ext| ext.eq_ignore_ascii_case("yml") || ext.eq_ignore_ascii_case("yaml"));
238 if !ext_ok {
239 return Err(CiError::InvalidArgument(
240 "workflow file must end with .yml or .yaml".to_string(),
241 ));
242 }
243 Ok(name.to_string())
244}
245
246#[must_use]
247pub fn file_stem(file: &str) -> String {
248 std::path::Path::new(file)
249 .file_stem()
250 .map(|s| s.to_string_lossy().into_owned())
251 .unwrap_or_else(|| file.to_string())
252}
253
254/// Returns whether a push to `branch`/`tag` (or neither) should trigger.
255#[must_use]
256pub fn push_trigger_matches(
257 trigger: &PushTrigger,
258 branch: Option<&str>,
259 tag: Option<&str>,
260) -> bool {
261 match (branch, tag) {
262 (Some(branch), None) => {
263 trigger.tags.is_empty() && ci_protocol::dsl::matches_patterns(&trigger.branches, branch)
264 }
265 (None, Some(tag)) => {
266 trigger.branches.is_empty() && ci_protocol::dsl::matches_patterns(&trigger.tags, tag)
267 }
268 (None, None) => false,
269 (Some(branch), Some(_)) => {
270 ci_protocol::dsl::matches_patterns(&trigger.branches, branch)
271 && ci_protocol::dsl::matches_patterns(&trigger.tags, tag.unwrap_or_default())
272 }
273 }
274}
275
276#[must_use]
277pub fn pull_request_action(
278 action: ci_protocol::events::PullRequestAction,
279) -> ci_protocol::dsl::PullRequestAction {
280 match action {
281 ci_protocol::events::PullRequestAction::Opened => {
282 ci_protocol::dsl::PullRequestAction::Opened
283 }
284 ci_protocol::events::PullRequestAction::Synchronize => {
285 ci_protocol::dsl::PullRequestAction::Synchronize
286 }
287 ci_protocol::events::PullRequestAction::Reopened => {
288 ci_protocol::dsl::PullRequestAction::Reopened
289 }
290 ci_protocol::events::PullRequestAction::Merged => {
291 ci_protocol::dsl::PullRequestAction::Merged
292 }
293 ci_protocol::events::PullRequestAction::Closed => {
294 ci_protocol::dsl::PullRequestAction::Closed
295 }
296 }
297}
298
299const DEFAULT_PR_ACTIONS: [ci_protocol::dsl::PullRequestAction; 3] = [
300 ci_protocol::dsl::PullRequestAction::Opened,
301 ci_protocol::dsl::PullRequestAction::Synchronize,
302 ci_protocol::dsl::PullRequestAction::Reopened,
303];
304
305#[must_use]
306pub fn pull_request_trigger_matches(
307 trigger: &PullRequestTrigger,
308 base_ref: &str,
309 action: ci_protocol::events::PullRequestAction,
310) -> bool {
311 let branch_ok = trigger.branches.is_empty()
312 || ci_protocol::dsl::matches_patterns(&trigger.branches, base_ref);
313 let wanted = pull_request_action(action);
314 let action_ok = if trigger.types.is_empty() {
315 DEFAULT_PR_ACTIONS.contains(&wanted)
316 } else {
317 trigger.types.contains(&wanted)
318 };
319 branch_ok && action_ok
320}
Added lib/ci/Cargo.toml View lib/ci/Cargo.toml
@@ -0,0 +1,36 @@
1[package]
2name = "ci"
3version.workspace = true
4edition.workspace = true
5
6[dependencies]
7auth = { workspace = true }
8ci-protocol = { workspace = true }
9config = { workspace = true }
10export = { workspace = true }
11git = { workspace = true }
12gitbaby = { workspace = true }
13models = { workspace = true }
14session = { workspace = true }
15space = { workspace = true }
16storage = { workspace = true }
17axum = { workspace = true }
18sea-orm = { workspace = true }
19serde = { workspace = true, features = ["derive"] }
20serde_json = { workspace = true }
21serde_yaml_ng = { workspace = true }
22utoipa = { workspace = true }
23reqwest = { workspace = true }
24tokio = { workspace = true, features = ["fs", "process", "time", "sync", "rt", "macros"] }
25tracing = { workspace = true }
26globset = { workspace = true }
27hmac = { workspace = true }
28sha2 = { workspace = true }
29base64 = { workspace = true }
30aes-gcm = { workspace = true }
31rand = { workspace = true }
32uuid = { workspace = true }
33time = { workspace = true }
34thiserror = { workspace = true }
35async-trait = { workspace = true }
36
Added lib/ci-protocol/src/dsl.rs View lib/ci-protocol/src/dsl.rs
@@ -0,0 +1,377 @@
1use std::collections::BTreeMap;
2
3use globset::{Glob, GlobSet, GlobSetBuilder};
4use serde::{Deserialize, Serialize};
5
6pub const WORKFLOW_DIR: &str = ".workflows";
7pub const MAX_JOBS: usize = 50;
8pub const MAX_STEPS: usize = 50;
9pub const DEFAULT_JOB_TIMEOUT_MINUTES: u64 = 60;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
12#[serde(deny_unknown_fields)]
13pub struct WorkflowDefinition {
14 #[serde(default, skip_serializing_if = "Option::is_none")]
15 pub name: Option<String>,
16 pub on: WorkflowTriggers,
17 #[serde(default)]
18 pub env: BTreeMap<String, String>,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub concurrency: Option<Concurrency>,
21 pub jobs: BTreeMap<String, Job>,
22}
23
24#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
25#[serde(deny_unknown_fields)]
26pub struct WorkflowTriggers {
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub push: Option<PushTrigger>,
29 #[serde(
30 default,
31 rename = "pull_request",
32 skip_serializing_if = "Option::is_none"
33 )]
34 pub pull_request: Option<PullRequestTrigger>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub workflow_dispatch: Option<WorkflowDispatchTrigger>,
37}
38
39impl WorkflowTriggers {
40 #[must_use]
41 pub fn is_empty(&self) -> bool {
42 self.push.is_none() && self.pull_request.is_none() && self.workflow_dispatch.is_none()
43 }
44}
45
46#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
47#[serde(deny_unknown_fields)]
48pub struct PushTrigger {
49 #[serde(default)]
50 pub branches: Vec<String>,
51 #[serde(default)]
52 pub tags: Vec<String>,
53}
54
55#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
56#[serde(deny_unknown_fields)]
57pub struct PullRequestTrigger {
58 #[serde(default)]
59 pub branches: Vec<String>,
60 #[serde(default)]
61 pub types: Vec<PullRequestAction>,
62}
63
64#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
65#[serde(deny_unknown_fields)]
66pub struct WorkflowDispatchTrigger {
67 #[serde(default)]
68 pub inputs: BTreeMap<String, String>,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, utoipa::ToSchema)]
72#[serde(rename_all = "snake_case")]
73pub enum PullRequestAction {
74 Opened,
75 Synchronize,
76 Reopened,
77 Merged,
78 Closed,
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
82#[serde(deny_unknown_fields)]
83pub struct Concurrency {
84 pub group: String,
85 #[serde(default)]
86 pub cancel_in_progress: bool,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
90#[serde(deny_unknown_fields)]
91pub struct Job {
92 #[serde(default = "default_runs_on", rename = "runs-on")]
93 pub runs_on: Vec<String>,
94 #[serde(default, rename = "if", skip_serializing_if = "Option::is_none")]
95 pub condition: Option<String>,
96 #[serde(default)]
97 pub needs: Vec<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub environment: Option<String>,
100 #[serde(default)]
101 pub permissions: MarkedPermissions,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub container: Option<String>,
104 #[serde(
105 default,
106 rename = "timeout-minutes",
107 skip_serializing_if = "Option::is_none"
108 )]
109 pub timeout_minutes: Option<u64>,
110 #[serde(default)]
111 pub env: BTreeMap<String, String>,
112 #[serde(default)]
113 pub artifacts: Vec<ArtifactSpec>,
114 pub steps: Vec<Step>,
115}
116
117fn default_runs_on() -> Vec<String> {
118 vec!["self-hosted".to_string()]
119}
120
121#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
122#[serde(deny_unknown_fields)]
123pub struct MarkedPermissions {
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub contents: Option<PermissionLevel>,
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
129#[serde(rename_all = "snake_case")]
130pub enum PermissionLevel {
131 Read,
132 Write,
133 None,
134}
135
136impl MarkedPermissions {
137 #[must_use]
138 pub fn contents_write(&self) -> bool {
139 matches!(self.contents, Some(PermissionLevel::Write))
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
144#[serde(deny_unknown_fields)]
145pub struct ArtifactSpec {
146 pub name: String,
147 pub path: String,
148 #[serde(default)]
149 pub always: bool,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
153#[serde(deny_unknown_fields)]
154pub struct Step {
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub name: Option<String>,
157 pub run: String,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub shell: Option<String>,
160 #[serde(
161 default,
162 rename = "working-directory",
163 skip_serializing_if = "Option::is_none"
164 )]
165 pub working_directory: Option<String>,
166 #[serde(default)]
167 pub env: BTreeMap<String, String>,
168 #[serde(
169 default,
170 rename = "timeout-minutes",
171 skip_serializing_if = "Option::is_none"
172 )]
173 pub timeout_minutes: Option<u64>,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
177#[error("{0}")]
178pub struct WorkflowParseError(pub String);
179
180pub fn parse_workflow_str(source: &str) -> Result<WorkflowDefinition, WorkflowParseError> {
181 let workflow: WorkflowDefinition =
182 serde_yaml_ng::from_str(source).map_err(|e| WorkflowParseError(e.to_string()))?;
183 validate_workflow(&workflow)?;
184 Ok(workflow)
185}
186
187pub fn validate_workflow(workflow: &WorkflowDefinition) -> Result<(), WorkflowParseError> {
188 if workflow.on.is_empty() {
189 return Err(WorkflowParseError(
190 "`on` must declare at least one trigger".to_string(),
191 ));
192 }
193 if workflow.jobs.is_empty() {
194 return Err(WorkflowParseError(
195 "`jobs` must declare at least one job".to_string(),
196 ));
197 }
198 if workflow.jobs.len() > MAX_JOBS {
199 return Err(WorkflowParseError(format!(
200 "too many jobs: {} (max {MAX_JOBS})",
201 workflow.jobs.len()
202 )));
203 }
204 for (name, job) in &workflow.jobs {
205 if name.trim().is_empty() || name.chars().count() > 128 {
206 return Err(WorkflowParseError(
207 "job name must be 1-128 characters".to_string(),
208 ));
209 }
210 if job.steps.is_empty() {
211 return Err(WorkflowParseError(format!(
212 "job `{name}` must declare at least one step"
213 )));
214 }
215 if job.steps.len() > MAX_STEPS {
216 return Err(WorkflowParseError(format!(
217 "job `{name}` has too many steps: {} (max {MAX_STEPS})",
218 job.steps.len()
219 )));
220 }
221 if job.runs_on.is_empty() || job.runs_on.iter().any(|l| l.trim().is_empty()) {
222 return Err(WorkflowParseError(format!(
223 "job `{name}` runs-on label must not be empty"
224 )));
225 }
226 if let Some(container) = &job.container
227 && container.trim().is_empty()
228 {
229 return Err(WorkflowParseError(format!(
230 "job `{name}` container must not be empty when set"
231 )));
232 }
233 for need in &job.needs {
234 if !workflow.jobs.contains_key(need) {
235 return Err(WorkflowParseError(format!(
236 "job `{name}` needs unknown job `{need}`"
237 )));
238 }
239 if need == name {
240 return Err(WorkflowParseError(format!(
241 "job `{name}` must not depend on itself"
242 )));
243 }
244 }
245 for step in &job.steps {
246 if step.run.trim().is_empty() {
247 return Err(WorkflowParseError(format!(
248 "job `{name}` has an empty run step"
249 )));
250 }
251 }
252 }
253 ensure_acyclic(workflow)?;
254 Ok(())
255}
256
257#[derive(Clone, Copy, PartialEq, Eq)]
258enum VisitMark {
259 Visiting,
260 Done,
261}
262
263fn visit_job<'a>(
264 nodes: &'a BTreeMap<String, Job>,
265 name: &'a str,
266 marks: &mut BTreeMap<&'a str, VisitMark>,
267) -> Result<(), WorkflowParseError> {
268 match marks.get(name) {
269 Some(VisitMark::Done) => return Ok(()),
270 Some(VisitMark::Visiting) => {
271 return Err(WorkflowParseError(format!(
272 "dependency cycle detected at job `{name}`"
273 )));
274 }
275 None => {}
276 }
277 marks.insert(name, VisitMark::Visiting);
278 let job = nodes
279 .get(name)
280 .ok_or_else(|| WorkflowParseError(format!("unknown job `{name}`")))?;
281 for need in &job.needs {
282 let key = nodes
283 .get_key_value(need.as_str())
284 .map(|(key, _)| key.as_str())
285 .ok_or_else(|| WorkflowParseError(format!("unknown job `{need}`")))?;
286 visit_job(nodes, key, marks)?;
287 }
288 marks.insert(name, VisitMark::Done);
289 Ok(())
290}
291
292fn ensure_acyclic(workflow: &WorkflowDefinition) -> Result<(), WorkflowParseError> {
293 let mut marks: BTreeMap<&str, VisitMark> = BTreeMap::new();
294 for name in workflow.jobs.keys() {
295 visit_job(&workflow.jobs, name.as_str(), &mut marks)?;
296 }
297 Ok(())
298}
299
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
301pub struct WorkflowSummary {
302 pub file: String,
303 pub name: String,
304 pub valid: bool,
305 pub errors: Vec<String>,
306 pub triggers: TriggerSummary,
307}
308
309#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
310pub struct TriggerSummary {
311 pub push_branches: Vec<String>,
312 pub push_tags: Vec<String>,
313 pub pull_request_branches: Vec<String>,
314 pub pull_request_types: Vec<PullRequestAction>,
315 pub workflow_dispatch: bool,
316}
317
318impl TriggerSummary {
319 #[must_use]
320 pub fn from_triggers(triggers: &WorkflowTriggers) -> Self {
321 Self {
322 push_branches: triggers
323 .push
324 .as_ref()
325 .map(|p| p.branches.clone())
326 .unwrap_or_default(),
327 push_tags: triggers
328 .push
329 .as_ref()
330 .map(|p| p.tags.clone())
331 .unwrap_or_default(),
332 pull_request_branches: triggers
333 .pull_request
334 .as_ref()
335 .map(|p| p.branches.clone())
336 .unwrap_or_default(),
337 pull_request_types: triggers
338 .pull_request
339 .as_ref()
340 .map(|p| p.types.clone())
341 .unwrap_or_default(),
342 workflow_dispatch: triggers.workflow_dispatch.is_some(),
343 }
344 }
345}
346
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub enum RefKind {
349 Branch,
350 Tag,
351}
352
353pub fn build_glob_set(patterns: &[String]) -> Option<GlobSet> {
354 if patterns.is_empty() {
355 return None;
356 }
357 let mut builder = GlobSetBuilder::new();
358 let mut any = false;
359 for pattern in patterns {
360 if let Ok(glob) = Glob::new(pattern) {
361 builder.add(glob);
362 any = true;
363 }
364 }
365 if !any {
366 return None;
367 }
368 builder.build().ok()
369}
370
371#[must_use]
372pub fn matches_patterns(patterns: &[String], value: &str) -> bool {
373 match build_glob_set(patterns) {
374 Some(set) => set.is_match(value),
375 None => false,
376 }
377}
Added lib/ci-protocol/src/events.rs View lib/ci-protocol/src/events.rs
@@ -0,0 +1,88 @@
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}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68pub struct PushEvent {
69 pub id: Uuid,
70 pub repo_id: Uuid,
71 pub space_id: Uuid,
72 pub actor_id: Option<Uuid>,
73 pub refs: Vec<RefUpdate>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct PullRequestEvent {
78 pub id: Uuid,
79 pub repo_id: Uuid,
80 pub space_id: Uuid,
81 pub actor_id: Option<Uuid>,
82 pub number: i64,
83 pub action: PullRequestAction,
84 pub head_sha: String,
85 pub head_ref: String,
86 pub base_sha: String,
87 pub base_ref: String,
88}
Added lib/ci-protocol/src/lib.rs View lib/ci-protocol/src/lib.rs
@@ -0,0 +1,6 @@
1pub mod dsl;
2pub mod events;
3pub mod runner;
4
5pub const PROTOCOL_VERSION: u32 = 1;
6pub const LOG_CHUNK_MAX_BYTES: usize = 262_144;
Added lib/ci-protocol/src/runner.rs View lib/ci-protocol/src/runner.rs
@@ -0,0 +1,208 @@
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}
18
19fn default_capacity() -> i32 {
20 1
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
24pub struct RegisterResponse {
25 pub runner_id: Uuid,
26 pub credential: String,
27 pub heartbeat_interval_secs: u64,
28 pub poll_timeout_secs: u64,
29 pub protocol_version: u32,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
33pub struct HeartbeatRequest {
34 #[serde(default)]
35 pub running_job_ids: Vec<Uuid>,
36}
37
38#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
39pub struct HeartbeatResponse {
40 #[serde(default)]
41 pub cancel: Vec<Uuid>,
42 pub next_heartbeat_secs: u64,
43}
44
45#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
46pub struct ClaimRequest {
47 #[serde(default)]
48 pub wait_secs: Option<u64>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
52pub struct ClaimResponse {
53 #[serde(default)]
54 pub job: Option<JobPayload>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
58pub struct LeaseDto {
59 pub token: String,
60 pub expires_at: OffsetDateTime,
61 pub heartbeat_interval_secs: u64,
62 pub attempt: i32,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
66pub struct ArtifactRefDto {
67 pub artifact_id: Uuid,
68 pub name: String,
69 pub upload_url: String,
70 pub sha256: Option<String>,
71 pub declared_size: Option<i64>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
75pub struct ArtifactSpecDto {
76 pub name: String,
77 pub path: String,
78 pub always: bool,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
82pub struct NeedsArtifactDto {
83 pub run_id: Uuid,
84 pub job_id: Uuid,
85 pub name: String,
86 pub download_url: String,
87 pub size: i64,
88 pub sha256: Option<String>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
92pub struct StepPayload {
93 pub index: i32,
94 pub name: String,
95 pub run: String,
96 pub shell: Option<String>,
97 pub working_directory: Option<String>,
98 pub env: BTreeMap<String, String>,
99 pub timeout_secs: Option<u64>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
103pub struct JobPayload {
104 pub job_id: Uuid,
105 pub run_id: Uuid,
106 pub repo_id: Uuid,
107 pub space_id: Uuid,
108 pub run_attempt: i32,
109 pub job_name: String,
110 pub clone_url: String,
111 pub commit_sha: String,
112 pub git_ref: String,
113 pub branch: Option<String>,
114 pub tag: Option<String>,
115 pub base_sha: Option<String>,
116 pub base_ref: Option<String>,
117 pub pr_number: Option<i64>,
118 pub event: String,
119 pub workflow_name: String,
120 pub workflow_file: String,
121 pub env: BTreeMap<String, String>,
122 pub secrets: BTreeMap<String, String>,
123 pub allow_git_token: bool,
124 pub container: Option<String>,
125 pub timeout_secs: u64,
126 pub steps: Vec<StepPayload>,
127 pub artifacts: Vec<ArtifactSpecDto>,
128 pub needs_artifacts: Vec<NeedsArtifactDto>,
129 pub lease: LeaseDto,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
133#[serde(rename_all = "snake_case")]
134pub enum StepReportState {
135 Started,
136 Finished,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
140pub struct StepReport {
141 pub state: StepReportState,
142 #[serde(default)]
143 pub exit_code: Option<i32>,
144 #[serde(default)]
145 pub name: Option<String>,
146 #[serde(default)]
147 pub shell: Option<String>,
148 #[serde(default)]
149 pub working_directory: Option<String>,
150 #[serde(default)]
151 pub script: Option<String>,
152 #[serde(default)]
153 pub timeout_secs: Option<u64>,
154 #[serde(default)]
155 pub started_at: Option<OffsetDateTime>,
156 #[serde(default)]
157 pub finished_at: Option<OffsetDateTime>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
161pub struct LogChunk {
162 pub seq: i32,
163 pub text: String,
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
167pub struct LogChunksRequest {
168 pub chunks: Vec<LogChunk>,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
172#[serde(rename_all = "snake_case")]
173pub enum JobFinishState {
174 Success,
175 Failure,
176 TimedOut,
177 Cancelled,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
181pub struct JobFinishRequest {
182 pub state: JobFinishState,
183 #[serde(default)]
184 pub exit_code: Option<i32>,
185 #[serde(default)]
186 pub error: Option<String>,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
190pub struct ArtifactInitRequest {
191 pub name: String,
192 pub size: i64,
193 #[serde(default)]
194 pub sha256: Option<String>,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
198pub struct ArtifactInitResponse {
199 pub artifact_id: Uuid,
200 pub upload_url: String,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
204pub struct ArtifactCompleteRequest {
205 pub size: i64,
206 #[serde(default)]
207 pub sha256: Option<String>,
208}
Added lib/ci-protocol/Cargo.toml View lib/ci-protocol/Cargo.toml
@@ -0,0 +1,14 @@
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 }
10serde_yaml_ng = { workspace = true }
11thiserror = { workspace = true }
12utoipa = { workspace = true }
13time = { workspace = true }
14uuid = { workspace = true }
Modified lib/config/src/admin.rs View lib/config/src/admin.rs
@@ -107,4 +107,4 @@
107107 }
108108 Ok(())
109109 }
110}
No newline at end of file
110}
Modified lib/config/src/auth.rs View lib/config/src/auth.rs
@@ -171,7 +171,9 @@
171171 }
172172 if let Some(key) = &self.chat_jwt_sec {
173173 if !key.is_empty() && key.as_bytes().len() < 32 {
174 return Err("auth.chat_jwt_sec must be at least 32 bytes when configured".to_string());
174 return Err(
175 "auth.chat_jwt_sec must be at least 32 bytes when configured".to_string(),
176 );
175177 }
176178 }
177179 Ok(())
Added lib/config/src/ci.rs View lib/config/src/ci.rs
@@ -0,0 +1,101 @@
1use serde::Deserialize;
2
3#[derive(Clone, Debug, Deserialize)]
4#[serde(default)]
5pub struct CiConfig {
6 pub enabled: bool,
7 pub secret_encryption_key: String,
8 pub event_queue_topic: String,
9 pub event_dlq_topic: String,
10 pub lease_secs: u64,
11 pub heartbeat_interval_secs: u64,
12 pub long_poll_timeout_secs: u64,
13 pub max_attempts: i32,
14 pub job_timeout_default_secs: u64,
15 pub job_timeout_max_secs: u64,
16 pub log_chunk_max_bytes: u64,
17 pub log_retention_days: i64,
18 pub artifact_retention_days: i64,
19 pub run_retention_days: i64,
20 pub artifact_max_bytes: u64,
21 pub outbound_max_attempts: i32,
22 pub outbound_backoff_base_secs: i64,
23 pub outbound_timeout_secs: u64,
24 pub scheduler_interval_secs: u64,
25 pub cancel_grace_secs: u64,
26}
27
28impl Default for CiConfig {
29 fn default() -> Self {
30 Self {
31 enabled: false,
32 secret_encryption_key: String::new(),
33 event_queue_topic: "gitdata:events".to_string(),
34 event_dlq_topic: "gitdata:events:dlq".to_string(),
35 lease_secs: 60,
36 heartbeat_interval_secs: 15,
37 long_poll_timeout_secs: 30,
38 max_attempts: 2,
39 job_timeout_default_secs: 3600,
40 job_timeout_max_secs: 21600,
41 log_chunk_max_bytes: 262_144,
42 log_retention_days: 30,
43 artifact_retention_days: 30,
44 run_retention_days: 180,
45 artifact_max_bytes: 2 * 1024 * 1024 * 1024,
46 outbound_max_attempts: 5,
47 outbound_backoff_base_secs: 60,
48 outbound_timeout_secs: 10,
49 scheduler_interval_secs: 2,
50 cancel_grace_secs: 120,
51 }
52 }
53}
54
55impl CiConfig {
56 pub fn validate(&self) -> Result<(), String> {
57 if !self.enabled {
58 return Ok(());
59 }
60 if self.secret_encryption_key.trim().is_empty() {
61 return Err(
62 "ci.secret_encryption_key must not be empty when ci is enabled".to_string(),
63 );
64 }
65 let decoded = base64::Engine::decode(
66 &base64::engine::general_purpose::STANDARD,
67 self.secret_encryption_key.trim(),
68 )
69 .map_err(|_| "ci.secret_encryption_key must be base64".to_string())?;
70 if decoded.len() < 32 {
71 return Err("ci.secret_encryption_key must decode to at least 32 bytes".to_string());
72 }
73 if self.event_queue_topic.trim().is_empty() || self.event_dlq_topic.trim().is_empty() {
74 return Err("ci event topics must not be empty".to_string());
75 }
76 if self.lease_secs == 0
77 || self.heartbeat_interval_secs == 0
78 || self.long_poll_timeout_secs == 0
79 || self.job_timeout_default_secs == 0
80 || self.job_timeout_max_secs == 0
81 || self.job_timeout_max_secs < self.job_timeout_default_secs
82 || self.log_chunk_max_bytes == 0
83 || self.scheduler_interval_secs == 0
84 {
85 return Err("ci timing and size limits must be greater than zero".to_string());
86 }
87 if self.max_attempts <= 0 || self.outbound_max_attempts <= 0 {
88 return Err("ci attempt limits must be greater than zero".to_string());
89 }
90 if self.log_retention_days < 0
91 || self.artifact_retention_days < 0
92 || self.run_retention_days < 0
93 {
94 return Err("ci retention days must not be negative".to_string());
95 }
96 if self.heartbeat_interval_secs >= self.lease_secs {
97 return Err("ci.heartbeat_interval_secs must be less than ci.lease_secs".to_string());
98 }
99 Ok(())
100 }
101}
Modified lib/config/src/lib.rs View lib/config/src/lib.rs
@@ -3,6 +3,7 @@
33pub mod auth;
44pub mod cache;
55pub mod chat;
6pub mod ci;
67pub mod database;
78pub mod email;
89pub mod email_queue;
@@ -15,6 +16,7 @@
1516pub mod pow;
1617pub mod quota;
1718pub mod redis;
19pub mod runner;
1820pub mod s3;
1921pub mod scoped;
2022pub mod search;
@@ -22,6 +24,7 @@
2224pub mod space;
2325pub mod user;
2426pub mod web;
27pub mod webhook;
2528
2629pub mod error;
2730pub mod loader;
@@ -33,6 +36,7 @@
3336use crate::auth::AuthConfig;
3437use crate::cache::CacheConfig;
3538use crate::chat::ChatConfig;
39use crate::ci::CiConfig;
3640use crate::database::DatabaseConfig;
3741use crate::email::EmailConfig;
3842use crate::email_queue::EmailQueueConfig;
@@ -44,12 +48,14 @@
4448use crate::issues::IssueConfig;
4549use crate::pow::PowConfig;
4650use crate::redis::RedisConfig;
51use crate::runner::RunnerConfig;
4752use crate::s3::S3Config;
4853use crate::search::SearchConfig;
4954use crate::session::SessionConfig;
5055use crate::space::SpaceConfig;
5156use crate::user::UserConfig;
5257use crate::web::WebConfig;
58use crate::webhook::WebhookConfig;
5359use serde::Deserialize;
5460
5561#[derive(Clone, Debug, Default, Deserialize)]
@@ -78,6 +84,9 @@
7884 pub im: ImServerConfig,
7985 pub health: HealthConfig,
8086 pub admin: AdminConfig,
87 pub ci: CiConfig,
88 pub webhook: WebhookConfig,
89 pub runner: RunnerConfig,
8190}
8291
8392impl AppConfig {
@@ -104,6 +113,8 @@
104113 self.im.is_valid().map_err(ConfigError::Validation)?;
105114 self.health.is_valid().map_err(ConfigError::Validation)?;
106115 self.admin.is_valid().map_err(ConfigError::Validation)?;
116 self.ci.validate().map_err(ConfigError::Validation)?;
117 self.webhook.is_valid().map_err(ConfigError::Validation)?;
107118 Ok(())
108119 }
109120}
Added lib/config/src/runner.rs View lib/config/src/runner.rs
@@ -0,0 +1,138 @@
1use figment::Figment;
2use figment::providers::{Env, Format, Serialized, Toml};
3use serde::Deserialize;
4
5use crate::error::ConfigError;
6
7pub const RUNNER_ENV_PREFIX: &str = "APP_RUNNER__";
8pub const DEFAULT_CONFIG_PATH: &str = "config/default.toml";
9pub const LOCAL_CONFIG_PATH: &str = "config/local.toml";
10
11#[derive(Clone, Debug, Deserialize, serde::Serialize)]
12#[serde(default)]
13pub struct RunnerConfig {
14 pub server_url: String,
15 pub registration_token: String,
16 pub name: String,
17 pub labels: Vec<String>,
18 pub capacity: i32,
19 pub workspace_root: String,
20 pub credential_path: String,
21 pub docker_enabled: bool,
22 pub default_shell: String,
23 pub pat: String,
24 pub git_binary: String,
25 pub docker_binary: String,
26 pub clone_timeout_secs: u64,
27 pub default_job_timeout_secs: u64,
28 pub poll_timeout_secs: u64,
29 pub heartbeat_interval_secs: u64,
30 pub log_flush_interval_ms: u64,
31 pub log_flush_bytes: usize,
32 pub keep_workspace: bool,
33 pub once: bool,
34}
35
36impl Default for RunnerConfig {
37 fn default() -> Self {
38 Self {
39 server_url: "http://127.0.0.1:3005".to_string(),
40 registration_token: String::new(),
41 name: String::new(),
42 labels: vec!["self-hosted".to_string()],
43 capacity: 1,
44 workspace_root: "/var/lib/gitdata-runner".to_string(),
45 credential_path: "/var/lib/gitdata-runner/credential".to_string(),
46 docker_enabled: false,
47 default_shell: "sh".to_string(),
48 pat: String::new(),
49 git_binary: "git".to_string(),
50 docker_binary: "docker".to_string(),
51 clone_timeout_secs: 1800,
52 default_job_timeout_secs: 3600,
53 poll_timeout_secs: 30,
54 heartbeat_interval_secs: 15,
55 log_flush_interval_ms: 1000,
56 log_flush_bytes: 65536,
57 keep_workspace: false,
58 once: false,
59 }
60 }
61}
62
63impl RunnerConfig {
64 pub fn load() -> Result<Self, ConfigError> {
65 let _ = dotenvy::dotenv();
66 let mut figment = Figment::new();
67 if std::path::Path::new(DEFAULT_CONFIG_PATH).exists() {
68 figment = figment.merge(Toml::file(DEFAULT_CONFIG_PATH));
69 }
70 if std::path::Path::new(LOCAL_CONFIG_PATH).exists() {
71 figment = figment.merge(Toml::file(LOCAL_CONFIG_PATH));
72 }
73 // The shared config files keep runner settings under `[runner]`, while
74 // `APP_RUNNER__*` maps onto the flat keys of this struct; layer both so
75 // a file-only config is not silently ignored.
76 let from_file: Self = figment.extract_inner("runner").unwrap_or_default();
77 let figment =
78 Figment::from(Serialized::defaults(from_file)).merge(Env::prefixed(RUNNER_ENV_PREFIX));
79 let cfg: Self = figment.extract().map_err(ConfigError::from)?;
80 cfg.validate()?;
81 Ok(cfg)
82 }
83
84 pub fn validate(&self) -> Result<(), ConfigError> {
85 if self.server_url.trim().is_empty() {
86 return Err(ConfigError::Validation(
87 "runner.server_url must not be empty".to_string(),
88 ));
89 }
90 if self.labels.is_empty() || self.labels.iter().any(|l| l.trim().is_empty()) {
91 return Err(ConfigError::Validation(
92 "runner.labels must contain at least one non-empty label".to_string(),
93 ));
94 }
95 if self.capacity <= 0 {
96 return Err(ConfigError::Validation(
97 "runner.capacity must be greater than zero".to_string(),
98 ));
99 }
100 if self.workspace_root.trim().is_empty() || self.credential_path.trim().is_empty() {
101 return Err(ConfigError::Validation(
102 "runner workspace_root and credential_path must not be empty".to_string(),
103 ));
104 }
105 if self.default_shell.trim().is_empty() {
106 return Err(ConfigError::Validation(
107 "runner.default_shell must not be empty".to_string(),
108 ));
109 }
110 if self.clone_timeout_secs == 0
111 || self.default_job_timeout_secs == 0
112 || self.poll_timeout_secs == 0
113 || self.heartbeat_interval_secs == 0
114 {
115 return Err(ConfigError::Validation(
116 "runner timeouts must be greater than zero".to_string(),
117 ));
118 }
119 let _ = Env::prefixed(RUNNER_ENV_PREFIX);
120 Ok(())
121 }
122
123 #[must_use]
124 pub fn resolved_name(&self) -> String {
125 if self.name.trim().is_empty() {
126 hostname()
127 } else {
128 self.name.clone()
129 }
130 }
131}
132
133fn hostname() -> String {
134 std::env::var("HOSTNAME")
135 .ok()
136 .filter(|h| !h.trim().is_empty())
137 .unwrap_or_else(|| "gitdata-runner".to_string())
138}
Modified lib/config/src/scoped.rs View lib/config/src/scoped.rs
@@ -6,6 +6,7 @@
66use crate::auth::AuthConfig;
77use crate::cache::CacheConfig;
88use crate::chat::ChatConfig;
9use crate::ci::CiConfig;
910use crate::database::DatabaseConfig;
1011use crate::email::EmailConfig;
1112use crate::email_queue::EmailQueueConfig;
@@ -25,6 +26,7 @@
2526use crate::space::SpaceConfig;
2627use crate::user::UserConfig;
2728use crate::web::WebConfig;
29use crate::webhook::WebhookConfig;
2830use crate::{AppConfig, loader};
2931
3032pub trait ScopedConfig: DeserializeOwned + Sized {
@@ -69,6 +71,53 @@
6971
7072#[derive(Clone, Debug, Default, Deserialize)]
7173#[serde(default)]
74pub struct WebhookServiceConfig {
75 pub database: DatabaseConfig,
76 pub redis: RedisConfig,
77 pub s3: S3Config,
78 pub email: EmailConfig,
79 pub email_queue: EmailQueueConfig,
80 pub cache: CacheConfig,
81 pub web: WebConfig,
82 pub git: GitConfig,
83 pub ci: CiConfig,
84 pub webhook: WebhookConfig,
85 pub health: HealthConfig,
86}
87
88impl WebhookServiceConfig {
89 pub fn into_app_config(self) -> AppConfig {
90 AppConfig {
91 database: self.database,
92 redis: self.redis,
93 s3: self.s3,
94 email: self.email,
95 email_queue: self.email_queue,
96 cache: self.cache,
97 web: self.web,
98 git: self.git,
99 ci: self.ci,
100 webhook: self.webhook,
101 health: self.health,
102 ..Default::default()
103 }
104 }
105}
106
107impl ScopedConfig for WebhookServiceConfig {
108 fn validate_scoped(&self) -> Result<(), String> {
109 self.database.validate()?;
110 self.redis.validate()?;
111 self.email.is_valid()?;
112 self.email_queue.is_valid()?;
113 self.ci.validate()?;
114 self.webhook.is_valid()?;
115 self.health.is_valid()
116 }
117}
118
119#[derive(Clone, Debug, Default, Deserialize)]
120#[serde(default)]
72121pub struct SynchostConfig {
73122 pub database: DatabaseConfig,
74123 pub redis: RedisConfig,
@@ -102,6 +151,7 @@
102151 pub web: WebConfig,
103152 pub git: GitConfig,
104153 pub githost: GithostSection,
154 pub ci: CiConfig,
105155}
106156
107157impl GithostConfig {
@@ -116,6 +166,7 @@
116166 web: self.web,
117167 git: self.git,
118168 githost: self.githost,
169 ci: self.ci,
119170 ..Default::default()
120171 }
121172 }
@@ -290,4 +341,4 @@
290341 self.import.validate()?;
291342 self.admin.is_valid()
292343 }
293}
No newline at end of file
344}
Added lib/config/src/webhook.rs View lib/config/src/webhook.rs
@@ -0,0 +1,53 @@
1use std::net::IpAddr;
2
3use serde::Deserialize;
4
5#[derive(Clone, Debug, Deserialize)]
6#[serde(default)]
7pub struct WebhookConfig {
8 pub enabled: Option<bool>,
9 pub bind_addr: Option<String>,
10 pub port: Option<u16>,
11 pub public_base_url: String,
12}
13
14impl Default for WebhookConfig {
15 fn default() -> Self {
16 Self {
17 enabled: None,
18 bind_addr: None,
19 port: None,
20 public_base_url: String::new(),
21 }
22 }
23}
24
25impl WebhookConfig {
26 pub fn enabled(&self) -> bool {
27 self.enabled.unwrap_or(true)
28 }
29
30 pub fn bind_addr(&self) -> &str {
31 self.bind_addr.as_deref().unwrap_or("0.0.0.0")
32 }
33
34 pub fn port(&self) -> u16 {
35 self.port.unwrap_or(3005)
36 }
37
38 pub fn is_valid(&self) -> Result<(), String> {
39 if !self.enabled() {
40 return Ok(());
41 }
42 if self.bind_addr().parse::<IpAddr>().is_err() {
43 return Err(format!(
44 "webhook.bind_addr is not a valid IP: {:?}",
45 self.bind_addr()
46 ));
47 }
48 if self.port == Some(0) {
49 return Err("webhook.port must be greater than 0".to_string());
50 }
51 Ok(())
52 }
53}
Modified lib/email/src/templates/verify_code.rs View lib/email/src/templates/verify_code.rs
@@ -52,5 +52,8 @@
5252 purpose_slug = slug;
5353 query.push(("purpose", purpose_slug));
5454 }
55 Some(append_query(&url_join(base_url, "/auth/verify-email"), &query))
55 Some(append_query(
56 &url_join(base_url, "/auth/verify-email"),
57 &query,
58 ))
5659}
Modified lib/export/src/jwt.rs View lib/export/src/jwt.rs
@@ -66,11 +66,7 @@
6666 .map_err(|e| JwtError::Invalid(e.to_string()))
6767}
6868
69pub fn verify_app_jwt(
70 secret: &str,
71 appid: &str,
72 token: &str,
73) -> Result<AppJwtClaims, JwtError> {
69pub fn verify_app_jwt(secret: &str, appid: &str, token: &str) -> Result<AppJwtClaims, JwtError> {
7470 if secret.is_empty() {
7571 return Err(JwtError::NotConfigured);
7672 }
Modified lib/export/src/lib.rs View lib/export/src/lib.rs
@@ -13,8 +13,8 @@
1313
1414use std::sync::Arc;
1515
16pub use api_error::is_unique_violation;
1617use config::AppConfig;
17pub use api_error::is_unique_violation;
1818pub use database::{AppDatabase, DatabaseRole};
1919pub use error::ExportError;
2020pub use redis::{AppRedis, RedisConnection};
Modified lib/export/src/pagination.rs View lib/export/src/pagination.rs
@@ -39,7 +39,11 @@
3939pub fn paginate<T>(items: Vec<T>, page: &PageQuery) -> Page<T> {
4040 let total = items.len() as i64;
4141 let (limit, offset) = page.page();
42 let items = items.into_iter().skip(offset as usize).take(limit as usize).collect();
42 let items = items
43 .into_iter()
44 .skip(offset as usize)
45 .take(limit as usize)
46 .collect();
4347 Page {
4448 items,
4549 total,
Modified lib/git/src/handler/branches/list.rs View lib/git/src/handler/branches/list.rs
@@ -36,7 +36,11 @@
3636 .map_err(GitApiError::from)?;
3737 let total = all.len() as i64;
3838 let (limit, offset) = page.page();
39 let items = all.into_iter().skip(offset as usize).take(limit as usize).collect();
39 let items = all
40 .into_iter()
41 .skip(offset as usize)
42 .take(limit as usize)
43 .collect();
4044 Ok(AppResponseData::ok(export::pagination::Page {
4145 items,
4246 total,
Modified lib/git/src/handler/branches/rename.rs View lib/git/src/handler/branches/rename.rs
@@ -44,12 +44,7 @@
4444 )
4545 .await?;
4646 service
47 .rename_branch(
48 path.space_name,
49 path.repo_name,
50 path.name,
51 cmd.new_name,
52 )
47 .rename_branch(path.space_name, path.repo_name, path.name, cmd.new_name)
5348 .await
5449 .map_err(GitApiError::from)?;
5550 Ok(AppResponseData::ok(NoData))
Modified lib/git/src/handler/conflicts/get_conflicts.rs View lib/git/src/handler/conflicts/get_conflicts.rs
@@ -41,13 +41,7 @@
4141 let out = match q.ancestor {
4242 Some(ancestor) => {
4343 service
44 .conflict_check_threeway(
45 path.space_name,
46 path.repo_name,
47 q.base,
48 q.head,
49 ancestor,
50 )
44 .conflict_check_threeway(path.space_name, path.repo_name, q.base, q.head, ancestor)
5145 .await
5246 }
5347 None => {
Modified lib/git/src/handler/html/blame.rs View lib/git/src/handler/html/blame.rs
@@ -6,8 +6,7 @@
66use maud::html;
77
88use crate::handler::html::error::html_err;
9use crate::handler::html::{
10 HtmlRepoPath, archived_class, resolve_view, response_html, short_sha, };
9use crate::handler::html::{HtmlRepoPath, archived_class, resolve_view, response_html, short_sha};
1110use crate::service::GitService;
1211use crate::share::access::BasicAuth;
1312
Modified lib/git/src/handler/html/blob_content.rs View lib/git/src/handler/html/blob_content.rs
@@ -5,7 +5,8 @@
55
66use crate::handler::html::error::html_err;
77use crate::handler::html::{
8 HtmlRepoPath, archived_class, format_size, resolve_view, response_html, };
8 HtmlRepoPath, archived_class, format_size, resolve_view, response_html,
9};
910use crate::service::GitService;
1011use crate::share::access::BasicAuth;
1112
Modified lib/git/src/handler/html/blob_header.rs View lib/git/src/handler/html/blob_header.rs
@@ -2,7 +2,6 @@
22use axum::response::Response;
33use maud::html;
44
5
65use crate::handler::html::{HtmlRepoPath, archived_class, resolve_view, response_html};
76use crate::service::GitService;
87use crate::share::access::BasicAuth;
Modified lib/git/src/handler/html/commits.rs View lib/git/src/handler/html/commits.rs
@@ -7,7 +7,8 @@
77use crate::handler::html::error::html_err;
88use crate::handler::html::{
99 HtmlRepoPath, archived_class, clamp_limit, first_line, iso_time_unix, relative_time_unix,
10 resolve_view, response_html, short_sha, };
10 resolve_view, response_html, short_sha,
11};
1112use crate::service::GitService;
1213use crate::service::commits::ListCommitsCmd;
1314use crate::share::access::BasicAuth;
Modified lib/git/src/handler/html/readme.rs View lib/git/src/handler/html/readme.rs
@@ -5,7 +5,8 @@
55
66use crate::handler::html::error::html_err;
77use crate::handler::html::{
8 HtmlRepoPath, archived_class, format_size, resolve_view, response_html, };
8 HtmlRepoPath, archived_class, format_size, resolve_view, response_html,
9};
910use crate::service::GitService;
1011use crate::share::access::BasicAuth;
1112
Modified lib/git/src/handler/html/tree.rs View lib/git/src/handler/html/tree.rs
@@ -8,7 +8,8 @@
88use crate::handler::html::error::html_err;
99use crate::handler::html::{
1010 HtmlRepoPath, archived_class, clamp_limit, first_line, iso_time, relative_time, resolve_view,
11 response_html, short_sha, };
11 response_html, short_sha,
12};
1213use crate::service::GitService;
1314use crate::share::access::BasicAuth;
1415
Modified lib/git/src/handler/html/tree_row.rs View lib/git/src/handler/html/tree_row.rs
@@ -10,7 +10,6 @@
1010use crate::service::GitService;
1111use crate::share::access::BasicAuth;
1212
13
1413#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
1514pub struct TreeRowQuery {
1615 #[serde(default)]
Modified lib/git/src/handler/lfs/mod.rs View lib/git/src/handler/lfs/mod.rs
@@ -20,12 +20,12 @@
2020 force: bool,
2121) -> Result<Uuid, ApiError> {
2222 if force {
23 Ok(
24 require_repo_admin(service, session, space_name, repo_name)
25 .await?
26 .1,
27 )
23 Ok(require_repo_admin(service, session, space_name, repo_name)
24 .await?
25 .1)
2826 } else {
29 Ok(require_writer(service, session, space_name, repo_name).await?.1)
27 Ok(require_writer(service, session, space_name, repo_name)
28 .await?
29 .1)
3030 }
3131}
Deleted lib/git/src/handler/pipelines/cancel.rs
@@ -1,36 +0,0 @@
1use axum::Json;
2use axum::extract::State;
3use export::rest::{AppResponseData, NoData};
4use session::Session;
5
6use crate::errors::{ApiError, GitApiError};
7use crate::handler::ensure_caller;
8use crate::service::GitService;
9
10#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)]
11pub struct PipelineCancelBody {
12 pub job_id: String,
13}
14
15#[utoipa::path(
16 post,
17 path = "/repos/_/pipelines/cancel",
18 tag = "pipelines",
19 description = "按作业 ID 将未完成流水线标记为请求取消。",
20 request_body = PipelineCancelBody,
21 responses(
22 (status = 200, description = "cancel pipeline job", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn cancel(
26 State(service): State<GitService>,
27 session: Session,
28 Json(body): Json<PipelineCancelBody>,
29) -> Result<AppResponseData<NoData>, ApiError> {
30 ensure_caller(&session)?;
31 service
32 .pipeline_cancel(body.job_id)
33 .await
34 .map_err(GitApiError::from)?;
35 Ok(AppResponseData::ok(NoData))
36}
Deleted lib/git/src/handler/pipelines/get_run.rs
@@ -1,44 +0,0 @@
1use axum::extract::{Path, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::{ApiError, GitApiError};
6use crate::handler::ensure_read_access;
7use crate::service::GitService;
8use crate::service::pipelines::PipelineRunDto;
9use crate::share::errors::GitError;
10
11#[utoipa::path(
12 get,
13 path = "/repos/{space_name}/{repo_name}/pipelines/{job_id}",
14 tag = "pipelines",
15 description = "读取指定流水线运行详情;其他仓库的任务按不存在处理。",
16 params(
17 ("space_name" = String, Path),
18 ("repo_name" = String, Path),
19 ("job_id" = String, Path, description = "pipeline run id")
20 ),
21 responses(
22 (status = 200, description = "pipeline run detail", body = AppResponseData<PipelineRunDto>),
23 (status = 404, description = "not found", body = AppResponseData<export::rest::NoData>)
24 )
25)]
26pub async fn get_run(
27 State(service): State<GitService>,
28 session: Session,
29 Path((space_name, repo_name, job_id)): Path<(String, String, String)>,
30) -> Result<AppResponseData<PipelineRunDto>, ApiError> {
31 let ctx = service
32 .context(space_name.clone(), repo_name.clone())
33 .await
34 .map_err(GitApiError::from)?;
35 ensure_read_access(&service, &session, &ctx).await?;
36 let run = service
37 .get_pipeline_run(job_id)
38 .await
39 .map_err(GitApiError::from)?;
40 if run.repo_id != ctx.model.id {
41 return Err(GitApiError::from(GitError::NotFound).into());
42 }
43 Ok(AppResponseData::ok(run))
44}
Deleted lib/git/src/handler/pipelines/list_runs.rs
@@ -1,56 +0,0 @@
1use axum::extract::{Path, Query, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5
6use crate::errors::{ApiError, GitApiError};
7use crate::handler::RepoPath;
8use crate::handler::ensure_read_access;
9use crate::service::GitService;
10use crate::service::pipelines::PipelineRunDto;
11use models::repo::pipeline_run::PipelineRunState;
12
13#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
14pub struct ListRunsQuery {
15 pub commit: Option<String>,
16 pub branch: Option<String>,
17 pub state: Option<PipelineRunState>,
18 pub limit: Option<u64>,
19 pub offset: Option<u64>,
20}
21
22#[utoipa::path(
23 get,
24 path = "/repos/{space_name}/{repo_name}/pipelines",
25 tag = "pipelines",
26 description = "按提交、分支和状态分页读取仓库流水线运行记录。",
27 params(RepoPath, ListRunsQuery),
28 responses(
29 (status = 200, description = "list pipeline runs", body = AppResponseData<Vec<PipelineRunDto>>)
30 )
31)]
32pub async fn list_runs(
33 State(service): State<GitService>,
34 session: Session,
35 Path(path): Path<RepoPath>,
36 Query(query): Query<ListRunsQuery>,
37) -> Result<AppResponseData<Vec<PipelineRunDto>>, ApiError> {
38 let ctx = service
39 .context(path.space_name.clone(), path.repo_name.clone())
40 .await
41 .map_err(GitApiError::from)?;
42 ensure_read_access(&service, &session, &ctx).await?;
43 let runs = service
44 .list_pipeline_runs(
45 path.space_name,
46 path.repo_name,
47 query.commit,
48 query.branch,
49 query.state,
50 query.limit,
51 query.offset,
52 )
53 .await
54 .map_err(GitApiError::from)?;
55 Ok(AppResponseData::ok(runs))
56}
Deleted lib/git/src/handler/pipelines/mod.rs
@@ -1,6 +0,0 @@
1pub mod cancel;
2pub mod get_run;
3pub mod list_runs;
4pub mod report_run;
5pub mod schedule;
6pub mod trigger;
Deleted lib/git/src/handler/pipelines/report_run.rs
@@ -1,41 +0,0 @@
1use axum::Json;
2use axum::extract::{Path, State};
3use export::rest::AppResponseData;
4use models::repo::pipeline_run::PipelineRunState;
5use time::OffsetDateTime;
6
7use crate::errors::{ApiError, GitApiError};
8use crate::service::GitService;
9use crate::service::pipelines::PipelineRunDto;
10
11#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)]
12pub struct PipelineReportBody {
13 pub state: PipelineRunState,
14 #[serde(default)]
15 pub started_at: Option<OffsetDateTime>,
16 #[serde(default)]
17 pub finished_at: Option<OffsetDateTime>,
18}
19
20#[utoipa::path(
21 post,
22 path = "/repos/_/pipelines/{job_id}/report",
23 tag = "pipelines",
24 description = "供流水线执行器回报运行状态和起止时间。",
25 params(("job_id" = String, Path)),
26 request_body = PipelineReportBody,
27 responses(
28 (status = 200, description = "pipeline run updated", body = AppResponseData<PipelineRunDto>)
29 )
30)]
31pub async fn report_run(
32 State(service): State<GitService>,
33 Path(job_id): Path<String>,
34 Json(body): Json<PipelineReportBody>,
35) -> Result<AppResponseData<PipelineRunDto>, ApiError> {
36 let run = service
37 .report_pipeline_run(job_id, body.state, body.started_at, body.finished_at)
38 .await
39 .map_err(GitApiError::from)?;
40 Ok(AppResponseData::ok(run))
41}
Deleted lib/git/src/handler/pipelines/schedule.rs
@@ -1,51 +0,0 @@
1use axum::Json;
2use axum::extract::{Path, Query, State};
3use export::rest::AppResponseData;
4use session::Session;
5
6use crate::errors::{ApiError, GitApiError};
7use crate::handler::{RepoPath, require_writer};
8use crate::service::GitService;
9use crate::service::pipelines::{JobDto, TriggerResultDto};
10
11#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
12pub struct PipelineScheduleQuery {
13 pub commit: String,
14}
15
16#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)]
17pub struct PipelineScheduleBody {
18 pub job: JobDto,
19}
20
21#[utoipa::path(
22 post,
23 path = "/repos/{space_name}/{repo_name}/pipelines/schedule",
24 tag = "pipelines",
25 description = "根据作业定义登记排队中的流水线运行记录。",
26 params(RepoPath, PipelineScheduleQuery),
27 request_body = PipelineScheduleBody,
28 responses(
29 (status = 200, description = "schedule pipeline job", body = AppResponseData<TriggerResultDto>)
30 )
31)]
32pub async fn schedule(
33 State(service): State<GitService>,
34 session: Session,
35 Path(path): Path<RepoPath>,
36 Query(query): Query<PipelineScheduleQuery>,
37 Json(body): Json<PipelineScheduleBody>,
38) -> Result<AppResponseData<TriggerResultDto>, ApiError> {
39 let (_ctx, caller) = require_writer(
40 &service,
41 &session,
42 path.space_name.clone(),
43 path.repo_name.clone(),
44 )
45 .await?;
46 let out = service
47 .pipeline_schedule(caller, path.space_name, path.repo_name, query.commit, body.job)
48 .await
49 .map_err(GitApiError::from)?;
50 Ok(AppResponseData::ok(out))
51}
Deleted lib/git/src/handler/pipelines/trigger.rs
@@ -1,44 +0,0 @@
1use axum::extract::{Path, Query, State};
2use export::rest::AppResponseData;
3use session::Session;
4
5use crate::errors::{ApiError, GitApiError};
6use crate::handler::{RepoPath, require_writer};
7use crate::service::GitService;
8use crate::service::pipelines::TriggerResultDto;
9
10#[derive(Debug, Clone, serde::Deserialize, utoipa::IntoParams)]
11pub struct PipelineTriggerQuery {
12 pub commit: String,
13 pub branch: String,
14}
15
16#[utoipa::path(
17 post,
18 path = "/repos/{space_name}/{repo_name}/pipelines/trigger",
19 tag = "pipelines",
20 description = "为指定提交与分支创建排队中的流水线运行记录。",
21 params(RepoPath, PipelineTriggerQuery),
22 responses(
23 (status = 200, description = "trigger pipeline", body = AppResponseData<TriggerResultDto>)
24 )
25)]
26pub async fn trigger(
27 State(service): State<GitService>,
28 session: Session,
29 Path(path): Path<RepoPath>,
30 Query(query): Query<PipelineTriggerQuery>,
31) -> Result<AppResponseData<TriggerResultDto>, ApiError> {
32 let (_ctx, caller) = require_writer(
33 &service,
34 &session,
35 path.space_name.clone(),
36 path.repo_name.clone(),
37 )
38 .await?;
39 let out = service
40 .pipeline_trigger(caller, path.space_name, path.repo_name, query.commit, query.branch)
41 .await
42 .map_err(GitApiError::from)?;
43 Ok(AppResponseData::ok(out))
44}
Modified lib/git/src/handler/remotes/list.rs View lib/git/src/handler/remotes/list.rs
@@ -3,8 +3,8 @@
33use session::Session;
44
55use crate::errors::{ApiError, GitApiError};
6use crate::handler::RepoPath;
67use crate::handler::ensure_read_access;
7use crate::handler::RepoPath;
88use crate::service::GitService;
99use crate::service::remotes::RemoteDto;
1010
Modified lib/git/src/handler/remotes/mod.rs View lib/git/src/handler/remotes/mod.rs
@@ -1,6 +1,6 @@
11pub mod add;
2pub mod list;
32pub mod clone_import;
43pub mod fetch;
4pub mod list;
55pub mod push;
66pub mod remove;
Modified lib/git/src/handler/tags/list.rs View lib/git/src/handler/tags/list.rs
@@ -36,7 +36,11 @@
3636 .map_err(GitApiError::from)?;
3737 let total = all.len() as i64;
3838 let (limit, offset) = page.page();
39 let items = all.into_iter().skip(offset as usize).take(limit as usize).collect();
39 let items = all
40 .into_iter()
41 .skip(offset as usize)
42 .take(limit as usize)
43 .collect();
4044 Ok(AppResponseData::ok(export::pagination::Page {
4145 items,
4246 total,
Modified lib/git/src/handler/mod.rs View lib/git/src/handler/mod.rs
@@ -17,7 +17,6 @@
1717pub mod packs;
1818pub mod patches;
1919pub mod path_log;
20pub mod pipelines;
2120pub mod refs;
2221pub mod remotes;
2322pub mod repos;
Modified lib/git/src/service/commits.rs View lib/git/src/service/commits.rs
@@ -191,7 +191,10 @@
191191 let b = baby.rev_parse(before).await.map_err(map_baby)?;
192192 format!("{a}..{b}")
193193 } else {
194 baby.rev_parse(&cmd.revision).await.map_err(map_baby)?.to_string()
194 baby.rev_parse(&cmd.revision)
195 .await
196 .map_err(map_baby)?
197 .to_string()
195198 };
196199 let rev_oid = rev_key.clone();
197200 let path_str = cmd
Modified lib/git/src/service/mod.rs View lib/git/src/service/mod.rs
@@ -51,7 +51,6 @@
5151pub mod packs;
5252pub mod patches;
5353pub mod path_log;
54pub mod pipelines;
5554pub mod protection;
5655pub mod refs;
5756pub mod remotes;
Deleted lib/git/src/service/pipelines.rs
@@ -1,226 +0,0 @@
1use models::repo::pipeline_run::{self, PipelineRunState};
2use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use uuid::Uuid;
6
7use crate::service::GitService;
8use crate::share::errors::GitError;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
11#[serde(rename_all = "lowercase")]
12pub enum CommitStatusState {
13 Pending,
14 Success,
15 Failure,
16 Error,
17 Warning,
18 Skipped,
19}
20
21#[derive(Debug, Clone, Serialize)]
22pub struct CommitStatusDto {
23 pub state: CommitStatusState,
24 pub context: String,
25 pub description: Option<String>,
26 pub target_url: Option<String>,
27 pub creator: Option<String>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
31pub struct JobDto {
32 pub id: String,
33 pub name: String,
34 pub state: CommitStatusState,
35 pub started_at: Option<OffsetDateTime>,
36 pub finished_at: Option<OffsetDateTime>,
37}
38
39#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
40pub struct TriggerResultDto {
41 pub job_id: String,
42 pub queued: bool,
43}
44
45#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
46pub struct PipelineRunDto {
47 pub id: Uuid,
48 pub repo_id: Uuid,
49 pub trigger_user_id: Uuid,
50 pub commit_sha: String,
51 pub branch: String,
52 pub state: PipelineRunState,
53 pub started_at: Option<OffsetDateTime>,
54 pub finished_at: Option<OffsetDateTime>,
55 pub created_at: OffsetDateTime,
56}
57
58impl From<pipeline_run::Model> for PipelineRunDto {
59 fn from(m: pipeline_run::Model) -> Self {
60 Self {
61 id: m.id,
62 repo_id: m.repo_id,
63 trigger_user_id: m.trigger_user_id,
64 commit_sha: m.commit_sha,
65 branch: m.branch,
66 state: m.state,
67 started_at: m.started_at,
68 finished_at: m.finished_at,
69 created_at: m.created_at,
70 }
71 }
72}
73
74#[derive(Debug, Clone, Serialize)]
75pub struct StatusListDto {
76 pub statuses: Vec<CommitStatusDto>,
77 pub total: u64,
78}
79
80impl GitService {
81 async fn insert_run(
82 &self,
83 repo_id: Uuid,
84 caller: Uuid,
85 commit_sha: String,
86 branch: String,
87 ) -> Result<Uuid, GitError> {
88 let id = Uuid::now_v7();
89 let now = OffsetDateTime::now_utc();
90 pipeline_run::ActiveModel {
91 id: Set(id),
92 repo_id: Set(repo_id),
93 trigger_user_id: Set(caller),
94 commit_sha: Set(commit_sha),
95 branch: Set(branch),
96 state: Set(PipelineRunState::Queued),
97 started_at: Set(None),
98 finished_at: Set(None),
99 created_at: Set(now),
100 updated_at: Set(now),
101 }
102 .insert(self.db_write())
103 .await?;
104 Ok(id)
105 }
106
107 pub async fn pipeline_trigger(
108 &self,
109 caller: Uuid,
110 space_name: String,
111 repo_name: String,
112 commit: String,
113 branch: String,
114 ) -> Result<TriggerResultDto, GitError> {
115 let ctx = self.context(space_name, repo_name).await?;
116 let job_id = self
117 .insert_run(ctx.model.id, caller, commit, branch)
118 .await?;
119 Ok(TriggerResultDto {
120 job_id: job_id.to_string(),
121 queued: true,
122 })
123 }
124
125 pub async fn pipeline_schedule(
126 &self,
127 caller: Uuid,
128 space_name: String,
129 repo_name: String,
130 commit: String,
131 job: JobDto,
132 ) -> Result<TriggerResultDto, GitError> {
133 let ctx = self.context(space_name, repo_name).await?;
134 let job_id = self
135 .insert_run(ctx.model.id, caller, commit, job.name)
136 .await?;
137 Ok(TriggerResultDto {
138 job_id: job_id.to_string(),
139 queued: true,
140 })
141 }
142
143 pub async fn pipeline_cancel(&self, job_id: String) -> Result<(), GitError> {
144 let id = Uuid::parse_str(&job_id).map_err(|_| GitError::NotFound)?;
145 let row = pipeline_run::Entity::find_by_id(id)
146 .one(self.db_read())
147 .await?
148 .ok_or(GitError::NotFound)?;
149 match row.state {
150 PipelineRunState::Queued | PipelineRunState::Running => {}
151 _ => {
152 return Err(GitError::Conflict(
153 "pipeline run already finished".to_string(),
154 ))
155 }
156 }
157 let mut am: pipeline_run::ActiveModel = row.into();
158 am.state = Set(PipelineRunState::CancelRequested);
159 am.updated_at = Set(OffsetDateTime::now_utc());
160 am.update(self.db_write()).await?;
161 Ok(())
162 }
163
164 pub async fn list_pipeline_runs(
165 &self,
166 space_name: String,
167 repo_name: String,
168 commit: Option<String>,
169 branch: Option<String>,
170 state: Option<PipelineRunState>,
171 limit: Option<u64>,
172 offset: Option<u64>,
173 ) -> Result<Vec<PipelineRunDto>, GitError> {
174 let ctx = self.context(space_name, repo_name).await?;
175 let mut q = pipeline_run::Entity::find()
176 .filter(pipeline_run::Column::RepoId.eq(ctx.model.id))
177 .order_by_desc(pipeline_run::Column::CreatedAt);
178 if let Some(commit) = commit.filter(|c| !c.is_empty()) {
179 q = q.filter(pipeline_run::Column::CommitSha.eq(commit));
180 }
181 if let Some(branch) = branch.filter(|b| !b.is_empty()) {
182 q = q.filter(pipeline_run::Column::Branch.eq(branch));
183 }
184 if let Some(state) = state {
185 q = q.filter(pipeline_run::Column::State.eq(state));
186 }
187 let limit = limit.unwrap_or(50).clamp(1, 100);
188 let offset = offset.unwrap_or(0).min(5000);
189 let rows = q.limit(limit).offset(offset).all(self.db_read()).await?;
190 Ok(rows.into_iter().map(PipelineRunDto::from).collect())
191 }
192
193 pub async fn get_pipeline_run(&self, job_id: String) -> Result<PipelineRunDto, GitError> {
194 let id = Uuid::parse_str(&job_id).map_err(|_| GitError::NotFound)?;
195 let row = pipeline_run::Entity::find_by_id(id)
196 .one(self.db_read())
197 .await?
198 .ok_or(GitError::NotFound)?;
199 Ok(PipelineRunDto::from(row))
200 }
201
202 pub async fn report_pipeline_run(
203 &self,
204 job_id: String,
205 state: PipelineRunState,
206 started_at: Option<OffsetDateTime>,
207 finished_at: Option<OffsetDateTime>,
208 ) -> Result<PipelineRunDto, GitError> {
209 let id = Uuid::parse_str(&job_id).map_err(|_| GitError::NotFound)?;
210 let row = pipeline_run::Entity::find_by_id(id)
211 .one(self.db_read())
212 .await?
213 .ok_or(GitError::NotFound)?;
214 let mut am: pipeline_run::ActiveModel = row.into();
215 am.state = Set(state);
216 if let Some(t) = started_at {
217 am.started_at = Set(Some(t));
218 }
219 if let Some(t) = finished_at {
220 am.finished_at = Set(Some(t));
221 }
222 am.updated_at = Set(OffsetDateTime::now_utc());
223 let updated = am.update(self.db_write()).await?;
224 Ok(PipelineRunDto::from(updated))
225 }
226}
Modified lib/git/src/service/protection.rs View lib/git/src/service/protection.rs
@@ -31,6 +31,7 @@
3131 pub protected_file_patterns: String,
3232 pub unprotected_file_patterns: String,
3333 pub enable_force_push: bool,
34 pub required_status_contexts: String,
3435 pub created_at: OffsetDateTime,
3536 pub updated_at: OffsetDateTime,
3637}
@@ -55,6 +56,7 @@
5556 protected_file_patterns: m.protected_file_patterns,
5657 unprotected_file_patterns: m.unprotected_file_patterns,
5758 enable_force_push: m.enable_force_push,
59 required_status_contexts: m.required_status_contexts,
5860 created_at: m.created_at,
5961 updated_at: m.updated_at,
6062 }
@@ -89,6 +91,8 @@
8991 pub unprotected_file_patterns: Option<String>,
9092 #[serde(default)]
9193 pub enable_force_push: Option<bool>,
94 #[serde(default)]
95 pub required_status_contexts: Option<String>,
9296}
9397
9498#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
@@ -195,13 +199,17 @@
195199 required_approvals: Set(required),
196200 enable_approvals_whitelist: Set(cmd.flags.enable_approvals_whitelist.unwrap_or(false)),
197201 block_on_rejected_reviews: Set(cmd.flags.block_on_rejected_reviews.unwrap_or(true)),
198 block_on_change_requested_reviews: Set(cmd.flags.block_on_change_requested_reviews.unwrap_or(true)),
202 block_on_change_requested_reviews: Set(cmd
203 .flags
204 .block_on_change_requested_reviews
205 .unwrap_or(true)),
199206 block_on_outdated_branch: Set(cmd.flags.block_on_outdated_branch.unwrap_or(false)),
200207 dismiss_stale_approvals: Set(cmd.flags.dismiss_stale_approvals.unwrap_or(true)),
201208 require_signed_commits: Set(cmd.flags.require_signed_commits.unwrap_or(false)),
202209 protected_file_patterns: Set(protected),
203210 unprotected_file_patterns: Set(unprotected),
204211 enable_force_push: Set(cmd.flags.enable_force_push.unwrap_or(false)),
212 required_status_contexts: Set(cmd.flags.required_status_contexts.unwrap_or_default()),
205213 created_at: Set(now),
206214 updated_at: Set(now),
207215 ..Default::default()
@@ -250,8 +258,11 @@
250258 }
251259 for (flag, value) in [
252260 (&cmd.flags.enable_push, &mut model.enable_push),
253 (&cmd.flags.enable_push_whitelist, &mut model.enable_push_whitelist),
254261 (
262 &cmd.flags.enable_push_whitelist,
263 &mut model.enable_push_whitelist,
264 ),
265 (
255266 &cmd.flags.enable_merge_whitelist,
256267 &mut model.enable_merge_whitelist,
257268 ),
Modified lib/git/src/service/remotes.rs View lib/git/src/service/remotes.rs
@@ -193,7 +193,12 @@
193193 validate_remote_url(&url)?;
194194 let ctx = self.context(space_name, repo_name).await?;
195195 let baby = self.baby(&ctx);
196 if baby.get_remote_address(&name).await.map_err(map_baby)?.is_some() {
196 if baby
197 .get_remote_address(&name)
198 .await
199 .map_err(map_baby)?
200 .is_some()
201 {
197202 run_git_soft(
198203 "remotes::set_url",
199204 &ctx.repo_root,
@@ -233,13 +238,11 @@
233238 else {
234239 continue;
235240 };
236 let entry = map
237 .entry(name.to_string())
238 .or_insert_with(|| RemoteDto {
239 name: name.to_string(),
240 fetch_url: None,
241 push_url: None,
242 });
241 let entry = map.entry(name.to_string()).or_insert_with(|| RemoteDto {
242 name: name.to_string(),
243 fetch_url: None,
244 push_url: None,
245 });
243246 if kind.starts_with("(fetch") {
244247 entry.fetch_url = Some(url.to_string());
245248 } else if kind.starts_with("(push") {
Modified lib/git/src/service/repos.rs View lib/git/src/service/repos.rs
@@ -203,10 +203,7 @@
203203 if base.is_empty() {
204204 return None;
205205 }
206 Some(format!(
207 "{}/{space}/{repo}.git",
208 base.trim_end_matches('/')
209 ))
206 Some(format!("{}/{space}/{repo}.git", base.trim_end_matches('/')))
210207 }
211208
212209 pub fn ssh_clone_url(&self, space: &str, repo: &str) -> Option<String> {
@@ -470,11 +467,7 @@
470467 self.repo_dto(ctx.model).await
471468 }
472469
473 pub async fn repo_viewer_state(
474 &self,
475 repo_id: Uuid,
476 uid: Uuid,
477 ) -> Result<RepoMeDto, GitError> {
470 pub async fn repo_viewer_state(&self, repo_id: Uuid, uid: Uuid) -> Result<RepoMeDto, GitError> {
478471 use models::repo::repo_star;
479472 use models::repo::repo_watch;
480473 let starred = repo_star::Entity::find()
@@ -742,7 +735,10 @@
742735 #[test]
743736 fn strips_http_port_but_keeps_host() {
744737 assert_eq!(strip_authority_port("localhost:3000"), "localhost");
745 assert_eq!(strip_authority_port("git.example.com:8443"), "git.example.com");
738 assert_eq!(
739 strip_authority_port("git.example.com:8443"),
740 "git.example.com"
741 );
746742 assert_eq!(strip_authority_port("[::1]:3000"), "[::1]");
747743 }
748744
Modified lib/git/src/share/access.rs View lib/git/src/share/access.rs
@@ -90,9 +90,7 @@
9090 .filter(
9191 sea_orm::Condition::any()
9292 .add(user_access_keys::Column::LastUsedAt.is_null())
93 .add(user_access_keys::Column::LastUsedAt.lt(
94 now - time::Duration::minutes(1),
95 )),
93 .add(user_access_keys::Column::LastUsedAt.lt(now - time::Duration::minutes(1))),
9694 )
9795 .exec(db.write())
9896 .await;
Modified lib/git/src/smart/dispatch.rs View lib/git/src/smart/dispatch.rs
@@ -17,7 +17,7 @@
1717use crate::smart::pre_receive::{self, parse_receive_pack_refs};
1818use crate::smart::rpc::{self, GitService};
1919use crate::smart::service::SmartService;
20use crate::sync::post_receive::enqueue_post_receive_push_logged;
20use crate::sync::post_receive::enqueue_post_receive_push_with_ci_logged;
2121
2222const MAX_RPC_BODY: usize = 512 * 1024 * 1024;
2323
@@ -125,6 +125,7 @@
125125 is_receive,
126126 service.queue.clone(),
127127 service.sync_settings.clone(),
128 service.ci_event_topic().map(|t| t.to_string()),
128129 space_id,
129130 ctx.model.id,
130131 actor_id,
@@ -176,6 +177,7 @@
176177 bool,
177178 Option<Arc<crate::sync::queue::SyncQueue>>,
178179 config::git::SyncSettings,
180 Option<String>,
179181 Uuid,
180182 Uuid,
181183 Option<Uuid>,
@@ -184,13 +186,12 @@
184186) -> impl futures::Stream<Item = Result<Bytes, axum::Error>> + Send + 'static {
185187 use futures::{StreamExt, stream};
186188 stream.chain(stream::once(async move {
187 let (is_receive, queue, settings, space_id, repo_id, actor_id, bare) = post;
188 if is_receive
189 && let Some(queue) = queue
190 {
191 enqueue_post_receive_push_logged(
189 let (is_receive, queue, settings, ci_topic, space_id, repo_id, actor_id, bare) = post;
190 if is_receive && let Some(queue) = queue {
191 enqueue_post_receive_push_with_ci_logged(
192192 &queue,
193193 &settings,
194 ci_topic.as_deref(),
194195 space_id,
195196 repo_id,
196197 actor_id,
Modified lib/git/src/smart/service.rs View lib/git/src/smart/service.rs
@@ -17,6 +17,8 @@
1717 pub kv: Option<Arc<AppRedis>>,
1818 pub queue: Option<Arc<SyncQueue>>,
1919 pub sync_settings: SyncSettings,
20 /// When set, pushes also publish a CI event on this Redis list topic.
21 pub ci_event_topic: Option<String>,
2022}
2123
2224impl SmartService {
@@ -26,9 +28,19 @@
2628 kv: None,
2729 queue: None,
2830 sync_settings: SyncSettings::default(),
31 ci_event_topic: None,
2932 }
3033 }
3134
35 pub fn with_ci_event_topic(mut self, topic: Option<String>) -> Self {
36 self.ci_event_topic = topic;
37 self
38 }
39
40 pub fn ci_event_topic(&self) -> Option<&str> {
41 self.ci_event_topic.as_deref()
42 }
43
3244 pub fn with_kv(mut self, kv: Option<Arc<AppRedis>>) -> Self {
3345 self.kv = kv;
3446 self
Modified lib/git/src/ssh/handler/channel.rs View lib/git/src/ssh/handler/channel.rs
@@ -13,7 +13,7 @@
1313use crate::ssh::error::SshError;
1414use crate::ssh::git_service::apply_hardened_env;
1515use crate::ssh::handler::{ChannelState, GitSshHandler};
16use crate::sync::post_receive::enqueue_post_receive_push_logged;
16use crate::sync::post_receive::enqueue_post_receive_push_with_ci_logged;
1717
1818pub const MAX_SSH_CHANNEL_BUF: usize = 64 * 1024 * 1024;
1919pub const MAX_GIT_OUTPUT_BUF: usize = 256 * 1024 * 1024;
@@ -157,6 +157,7 @@
157157 let push_coordinator = self.service.push_coordinator();
158158 let queue = self.service.smart.queue.clone();
159159 let sync_settings = self.service.sync_settings.clone();
160 let ci_topic = self.service.smart.ci_event_topic().map(|t| t.to_string());
160161 let ssh_service = self.service.clone();
161162
162163 let db = self.service.smart.git.state.database.clone();
@@ -296,9 +297,10 @@
296297 && let (Some(repo_id), Some(space_id), Some(queue)) =
297298 (repo_id, space_id, &queue)
298299 {
299 enqueue_post_receive_push_logged(
300 enqueue_post_receive_push_with_ci_logged(
300301 queue,
301302 &sync_settings,
303 ci_topic.as_deref(),
302304 space_id,
303305 repo_id,
304306 actor_id,
Modified lib/git/src/ssh/service.rs View lib/git/src/ssh/service.rs
@@ -32,4 +32,9 @@
3232 };
3333 PushCoordinator::new(backend)
3434 }
35
36 pub fn with_ci_event_topic(mut self, topic: Option<String>) -> Self {
37 self.smart = self.smart.with_ci_event_topic(topic);
38 self
39 }
3540}
Modified lib/git/src/sync/mod.rs View lib/git/src/sync/mod.rs
@@ -22,7 +22,9 @@
2222 enqueue_post_receive_dlq,
2323};
2424pub use lock::{SyncLock, acquire_lock};
25pub use post_receive::{enqueue_post_receive_push, prepare_post_receive};
25pub use post_receive::{
26 enqueue_post_receive_push, enqueue_post_receive_push_with_ci_logged, prepare_post_receive,
27};
2628pub use queue::SyncQueue;
2729pub use sync::RepoSync;
2830pub use webhook::{NoopWebhookTx, WebhookTx};
Modified lib/git/src/sync/post_receive.rs View lib/git/src/sync/post_receive.rs
@@ -142,10 +142,66 @@
142142 actor_id: Option<Uuid>,
143143 bare: &Path,
144144) -> Result<usize, EnqueueError> {
145 enqueue_push_impl(queue, settings, None, channel_id, repo_id, actor_id, bare).await
146}
147
148/// Enqueues the sync task and (optionally) publishes a CI push event from the
149/// same capture file read.
150pub async fn enqueue_post_receive_push_with_ci(
151 queue: &SyncQueue,
152 settings: &SyncSettings,
153 ci_topic: Option<&str>,
154 channel_id: Uuid,
155 repo_id: Uuid,
156 actor_id: Option<Uuid>,
157 bare: &Path,
158) -> Result<usize, EnqueueError> {
159 enqueue_push_impl(
160 queue, settings, ci_topic, channel_id, repo_id, actor_id, bare,
161 )
162 .await
163}
164
165async fn enqueue_push_impl(
166 queue: &SyncQueue,
167 settings: &SyncSettings,
168 ci_topic: Option<&str>,
169 channel_id: Uuid,
170 repo_id: Uuid,
171 actor_id: Option<Uuid>,
172 bare: &Path,
173) -> Result<usize, EnqueueError> {
145174 let refs = take_post_receive_capture(bare);
146175 if refs.is_empty() {
147176 return Ok(0);
148177 }
178 if let Some(topic) = ci_topic {
179 let ci_refs: Vec<ci_protocol::events::RefUpdate> = refs
180 .iter()
181 .map(|r| ci_protocol::events::RefUpdate {
182 name: r.name.clone(),
183 old_oid: r.old_oid.clone(),
184 new_oid: r.new_oid.clone(),
185 })
186 .collect();
187 if ci_refs.iter().any(|r| !r.is_delete()) {
188 let event = ci_protocol::events::CiEvent::Push(ci_protocol::events::PushEvent {
189 id: Uuid::now_v7(),
190 repo_id,
191 space_id: channel_id,
192 actor_id,
193 refs: ci_refs,
194 });
195 match serde_json::to_vec(&event) {
196 Ok(payload) => {
197 if let Err(error) = queue.publish_raw(topic, None, &payload).await {
198 tracing::warn!(%error, "ci push event publish failed");
199 }
200 }
201 Err(error) => tracing::warn!(%error, "ci push event serialize failed"),
202 }
203 }
204 }
149205 let task = PostReceiveTask::new(repo_id, channel_id, actor_id, refs);
150206 enqueue_post_receive(queue, settings, &task).await?;
151207 Ok(task.refs.len())
@@ -178,4 +234,33 @@
178234 }
179235}
180236
237pub async fn enqueue_post_receive_push_with_ci_logged(
238 queue: &SyncQueue,
239 settings: &SyncSettings,
240 ci_topic: Option<&str>,
241 space_id: Uuid,
242 repo_id: Uuid,
243 actor_id: Option<Uuid>,
244 bare: &Path,
245) {
246 match enqueue_post_receive_push_with_ci(
247 queue, settings, ci_topic, space_id, repo_id, actor_id, bare,
248 )
249 .await
250 {
251 Ok(n) if n > 0 => tracing::debug!(
252 count = n,
253 space_id = %space_id,
254 repo_id = %repo_id,
255 "enqueued post-receive ref updates and ci event"
256 ),
257 Ok(_) => tracing::debug!(space_id = %space_id, repo_id = %repo_id, "no refs to enqueue"),
258 Err(e) => tracing::warn!(
259 error = %e,
260 space_id = %space_id,
261 repo_id = %repo_id,
262 "post-receive enqueue failed"
263 ),
264 }
265}
181266pub use crate::sync::event::EnqueueError;
Modified lib/git/src/openapi.rs View lib/git/src/openapi.rs
@@ -60,10 +60,6 @@
6060 handler::patches::generate::patch_generate,
6161 handler::patches::parse::patch_parse,
6262 handler::path_log::path_log,
63 handler::pipelines::get_run::get_run,
64 handler::pipelines::list_runs::list_runs,
65 handler::pipelines::schedule::schedule,
66 handler::pipelines::trigger::trigger,
6763 handler::remotes::add::upsert_remote,
6864 handler::remotes::list::list_remotes,
6965 handler::remotes::clone_import::clone_import,
@@ -162,11 +158,6 @@
162158 crate::service::patches::ParsedPatchDto,
163159 crate::service::patches::PatchDto,
164160 crate::service::path_log::PathLogEntryDto,
165 crate::service::pipelines::CommitStatusState,
166 crate::service::pipelines::JobDto,
167 crate::service::pipelines::TriggerResultDto,
168 crate::service::pipelines::PipelineRunDto,
169 models::repo::pipeline_run::PipelineRunState,
170161 crate::service::refs::BranchInfoDto,
171162 crate::service::refs::HeadInfoDto,
172163 crate::service::refs::UpdateRefResultDto,
@@ -212,8 +203,6 @@
212203 crate::handler::lfs::cleanup_unreferenced::LfsCleanupResponse,
213204 crate::handler::lfs::lock::LfsLockBody,
214205 crate::handler::patches::apply::PatchApplyBody,
215 crate::handler::pipelines::cancel::PipelineCancelBody,
216 crate::handler::pipelines::schedule::PipelineScheduleBody,
217206 crate::handler::refs::is_object_in_ref::IsObjectInRefResponse,
218207 crate::handler::remotes::add::RemoteAddBody,
219208 crate::handler::remotes::clone_import::RemoteImportBody,
Modified lib/git/src/routes.rs View lib/git/src/routes.rs
@@ -52,12 +52,6 @@
5252use crate::handler::patches::generate::patch_generate;
5353use crate::handler::patches::parse::patch_parse;
5454use crate::handler::path_log::path_log;
55use crate::handler::pipelines::cancel::cancel;
56use crate::handler::pipelines::get_run::get_run;
57use crate::handler::pipelines::list_runs::list_runs;
58use crate::handler::pipelines::report_run::report_run;
59use crate::handler::pipelines::schedule::schedule;
60use crate::handler::pipelines::trigger::trigger;
6155use crate::handler::refs::branches::refs_branches;
6256use crate::handler::refs::delete::refs_delete;
6357use crate::handler::refs::filtered::refs_list_filtered as filtered;
@@ -243,14 +237,7 @@
243237 "/{space_name}/{repo_name}/hooks/{script}",
244238 post(execute_script),
245239 )
246 .route("/{space_name}/{repo_name}/pipelines/trigger", post(trigger))
247 .route("/{space_name}/{repo_name}/pipelines", get(list_runs))
248 .route("/{space_name}/{repo_name}/pipelines/{job_id}", get(get_run))
249240 .route(
250 "/{space_name}/{repo_name}/pipelines/schedule",
251 post(schedule),
252 )
253 .route(
254241 "/{space_name}/{repo_name}/analysis/languages",
255242 get(languages_read).post(languages),
256243 )
@@ -353,8 +340,6 @@
353340 post(sync_commit),
354341 )
355342 .route("/{space_name}/{repo_name}/patch/apply", post(patch_apply))
356 .route("/_/pipelines/cancel", post(cancel))
357 .route("/_/pipelines/{job_id}/report", post(report_run))
358343 .route("/{space_name}/{repo_name}/packs/stats", get(pack_stats))
359344 .route("/{space_name}/{repo_name}/packs/ingest", post(pack_ingest))
360345 .route("/{space_name}/{repo_name}/packs/repack", post(pack_repack))
Modified lib/git/Cargo.toml View lib/git/Cargo.toml
@@ -5,6 +5,7 @@
55
66[dependencies]
77gitbaby = { workspace = true }
8ci-protocol = { workspace = true }
89export = { workspace = true }
910config = { workspace = true }
1011models = { workspace = true }
@@ -39,3 +40,4 @@
3940utoipa = { workspace = true }
4041maud = { workspace = true }
4142pulldown-cmark = { workspace = true }
43
Modified lib/issues/src/handlers/comments/create_comment.rs View lib/issues/src/handlers/comments/create_comment.rs
@@ -32,7 +32,9 @@
3232 Json(cmd): Json<CreateCommentCmd>,
3333) -> Result<AppResponseData<CommentDto>, ApiError> {
3434 let uid = require_uid(&session)?;
35 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
35 let issue_id = service
36 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
37 .await?;
3638 let dto = service
3739 .create_comment(uid, &path.space_name, path.board_id, issue_id, cmd)
3840 .await?;
Modified lib/issues/src/handlers/comments/delete_comment.rs View lib/issues/src/handlers/comments/delete_comment.rs
@@ -26,7 +26,9 @@
2626 Path(path): Path<CommentPath>,
2727) -> Result<AppResponseData<NoData>, ApiError> {
2828 let uid = require_uid(&session)?;
29 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
29 let issue_id = service
30 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
31 .await?;
3032 service
3133 .delete_comment(
3234 uid,
Modified lib/issues/src/handlers/comments/get_comment.rs View lib/issues/src/handlers/comments/get_comment.rs
@@ -26,7 +26,9 @@
2626 Path(path): Path<CommentPath>,
2727) -> Result<AppResponseData<CommentDto>, ApiError> {
2828 let uid = require_uid(&session)?;
29 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
29 let issue_id = service
30 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
31 .await?;
3032 let dto = service
3133 .get_comment(
3234 uid,
Modified lib/issues/src/handlers/comments/list_comments.rs View lib/issues/src/handlers/comments/list_comments.rs
@@ -28,7 +28,9 @@
2828 Query(q): Query<ListCommentQuery>,
2929) -> Result<AppResponseData<CommentPage>, ApiError> {
3030 let uid = require_uid(&session)?;
31 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
31 let issue_id = service
32 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
33 .await?;
3234 let page = service
3335 .list_comments(uid, &path.space_name, path.board_id, issue_id, q)
3436 .await?;
Modified lib/issues/src/handlers/comments/mod.rs View lib/issues/src/handlers/comments/mod.rs
@@ -2,5 +2,5 @@
22pub mod delete_comment;
33pub mod get_comment;
44pub mod list_comments;
5pub mod list_comments_tree;
56pub mod update_comment;
6pub mod list_comments_tree;
Modified lib/issues/src/handlers/comments/update_comment.rs View lib/issues/src/handlers/comments/update_comment.rs
@@ -29,7 +29,9 @@
2929 Json(cmd): Json<UpdateCommentCmd>,
3030) -> Result<AppResponseData<CommentDto>, ApiError> {
3131 let uid = require_uid(&session)?;
32 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
32 let issue_id = service
33 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
34 .await?;
3335 let dto = service
3436 .update_comment(
3537 uid,
Modified lib/issues/src/handlers/content_history/list_content_history.rs View lib/issues/src/handlers/content_history/list_content_history.rs
@@ -29,7 +29,9 @@
2929 Query(q): Query<ListContentHistoryQuery>,
3030) -> Result<AppResponseData<ContentHistoryPage>, ApiError> {
3131 let uid = require_uid(&session)?;
32 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
32 let issue_id = service
33 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
34 .await?;
3335 let page = service
3436 .list_content_history(uid, &path.space_name, path.board_id, issue_id, q)
3537 .await?;
Modified lib/issues/src/handlers/dependencies/create_dependency.rs View lib/issues/src/handlers/dependencies/create_dependency.rs
@@ -31,7 +31,9 @@
3131 Json(cmd): Json<CreateDependencyCmd>,
3232) -> Result<AppResponseData<DependencyDto>, ApiError> {
3333 let uid = require_uid(&session)?;
34 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
34 let issue_id = service
35 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
36 .await?;
3537 let dto = service
3638 .create_dependency(uid, &path.space_name, path.board_id, issue_id, cmd)
3739 .await?;
Modified lib/issues/src/handlers/dependencies/delete_dependency.rs View lib/issues/src/handlers/dependencies/delete_dependency.rs
@@ -26,15 +26,11 @@
2626 Path(path): Path<DependencyPath>,
2727) -> Result<AppResponseData<NoData>, ApiError> {
2828 let uid = require_uid(&session)?;
29 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
29 let issue_id = service
30 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
31 .await?;
3032 service
31 .delete_dependency(
32 uid,
33 &path.space_name,
34 path.board_id,
35 issue_id,
36 path.dep_id,
37 )
33 .delete_dependency(uid, &path.space_name, path.board_id, issue_id, path.dep_id)
3834 .await?;
3935 Ok(AppResponseData::ok(NoData))
4036}
Modified lib/issues/src/handlers/dependencies/list_dependencies.rs View lib/issues/src/handlers/dependencies/list_dependencies.rs
@@ -27,7 +27,9 @@
2727 Path(path): Path<IssuePath>,
2828) -> Result<AppResponseData<Vec<DependencyDto>>, ApiError> {
2929 let uid = require_uid(&session)?;
30 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
30 let issue_id = service
31 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
32 .await?;
3133 let deps = service
3234 .list_dependencies(uid, &path.space_name, path.board_id, issue_id)
3335 .await?;
Modified lib/issues/src/handlers/events/list_events.rs View lib/issues/src/handlers/events/list_events.rs
@@ -27,7 +27,9 @@
2727 Query(q): Query<ListEventQuery>,
2828) -> Result<AppResponseData<EventPage>, ApiError> {
2929 let uid = require_uid(&session)?;
30 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
30 let issue_id = service
31 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
32 .await?;
3133 let page = service
3234 .list_events(uid, &path.space_name, path.board_id, issue_id, q)
3335 .await?;
Modified lib/issues/src/handlers/issue_users/list_issue_users.rs View lib/issues/src/handlers/issue_users/list_issue_users.rs
@@ -28,7 +28,9 @@
2828 Query(q): Query<ListIssueUserQuery>,
2929) -> Result<AppResponseData<IssueUserPage>, ApiError> {
3030 let uid = require_uid(&session)?;
31 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
31 let issue_id = service
32 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
33 .await?;
3234 let page = service
3335 .list_issue_users(uid, &path.space_name, path.board_id, issue_id, q)
3436 .await?;
Modified lib/issues/src/handlers/issues/close_issue.rs View lib/issues/src/handlers/issues/close_issue.rs
@@ -27,7 +27,9 @@
2727 Path(path): Path<IssuePath>,
2828) -> Result<AppResponseData<IssueDto>, ApiError> {
2929 let uid = require_uid(&session)?;
30 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
30 let issue_id = service
31 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
32 .await?;
3133 let dto = service
3234 .close_issue(uid, &path.space_name, path.board_id, issue_id, true)
3335 .await?;
Modified lib/issues/src/handlers/issues/delete_issue.rs View lib/issues/src/handlers/issues/delete_issue.rs
@@ -26,7 +26,9 @@
2626 Path(path): Path<IssuePath>,
2727) -> Result<AppResponseData<NoData>, ApiError> {
2828 let uid = require_uid(&session)?;
29 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
29 let issue_id = service
30 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
31 .await?;
3032 service
3133 .delete_issue(uid, &path.space_name, path.board_id, issue_id)
3234 .await?;
Modified lib/issues/src/handlers/issues/get_issue.rs View lib/issues/src/handlers/issues/get_issue.rs
@@ -26,7 +26,9 @@
2626 Path(path): Path<IssuePath>,
2727) -> Result<AppResponseData<IssueDto>, ApiError> {
2828 let uid = require_uid(&session)?;
29 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
29 let issue_id = service
30 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
31 .await?;
3032 let dto = service
3133 .get_issue(uid, &path.space_name, path.board_id, issue_id)
3234 .await?;
Modified lib/issues/src/handlers/issues/mod.rs View lib/issues/src/handlers/issues/mod.rs
@@ -1,3 +1,4 @@
1pub mod bulk_issues;
12pub mod close_issue;
23pub mod create_issue;
34pub mod delete_issue;
@@ -5,4 +6,3 @@
56pub mod list_issues;
67pub mod reopen_issue;
78pub mod update_issue;
8pub mod bulk_issues;
Modified lib/issues/src/handlers/issues/reopen_issue.rs View lib/issues/src/handlers/issues/reopen_issue.rs
@@ -27,7 +27,9 @@
2727 Path(path): Path<IssuePath>,
2828) -> Result<AppResponseData<IssueDto>, ApiError> {
2929 let uid = require_uid(&session)?;
30 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
30 let issue_id = service
31 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
32 .await?;
3133 let dto = service
3234 .close_issue(uid, &path.space_name, path.board_id, issue_id, false)
3335 .await?;
Modified lib/issues/src/handlers/issues/update_issue.rs View lib/issues/src/handlers/issues/update_issue.rs
@@ -29,7 +29,9 @@
2929 Json(cmd): Json<UpdateIssueCmd>,
3030) -> Result<AppResponseData<IssueDto>, ApiError> {
3131 let uid = require_uid(&session)?;
32 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
32 let issue_id = service
33 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
34 .await?;
3335 let dto = service
3436 .update_issue(uid, &path.space_name, path.board_id, issue_id, cmd)
3537 .await?;
Modified lib/issues/src/handlers/labels/bind_label.rs View lib/issues/src/handlers/labels/bind_label.rs
@@ -27,7 +27,9 @@
2727 Path(path): Path<IssueLabelPath>,
2828) -> Result<AppResponseData<NoData>, ApiError> {
2929 let uid = require_uid(&session)?;
30 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
30 let issue_id = service
31 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
32 .await?;
3133 service
3234 .bind_label(
3335 uid,
Modified lib/issues/src/handlers/labels/list_labels_for_issue.rs View lib/issues/src/handlers/labels/list_labels_for_issue.rs
@@ -27,7 +27,9 @@
2727 Path(path): Path<IssuePath>,
2828) -> Result<AppResponseData<Vec<LabelDto>>, ApiError> {
2929 let uid = require_uid(&session)?;
30 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
30 let issue_id = service
31 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
32 .await?;
3133 let labels = service
3234 .list_labels_for_issue(uid, &path.space_name, path.board_id, issue_id)
3335 .await?;
Modified lib/issues/src/handlers/labels/unbind_label.rs View lib/issues/src/handlers/labels/unbind_label.rs
@@ -26,7 +26,9 @@
2626 Path(path): Path<IssueLabelPath>,
2727) -> Result<AppResponseData<NoData>, ApiError> {
2828 let uid = require_uid(&session)?;
29 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
29 let issue_id = service
30 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
31 .await?;
3032 service
3133 .unbind_label(
3234 uid,
Modified lib/issues/src/handlers/members/create_member.rs View lib/issues/src/handlers/members/create_member.rs
@@ -31,7 +31,9 @@
3131 Json(cmd): Json<CreateIssueMemberCmd>,
3232) -> Result<AppResponseData<IssueMemberDto>, ApiError> {
3333 let uid = require_uid(&session)?;
34 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
34 let issue_id = service
35 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
36 .await?;
3537 let dto = service
3638 .create_member(uid, &path.space_name, path.board_id, issue_id, cmd)
3739 .await?;
Modified lib/issues/src/handlers/members/delete_member.rs View lib/issues/src/handlers/members/delete_member.rs
@@ -26,7 +26,9 @@
2626 Path(path): Path<IssueMemberPath>,
2727) -> Result<AppResponseData<NoData>, ApiError> {
2828 let uid = require_uid(&session)?;
29 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
29 let issue_id = service
30 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
31 .await?;
3032 service
3133 .delete_member(
3234 uid,
Modified lib/issues/src/handlers/members/list_members.rs View lib/issues/src/handlers/members/list_members.rs
@@ -28,7 +28,9 @@
2828 Query(q): Query<ListIssueMemberQuery>,
2929) -> Result<AppResponseData<IssueMemberPage>, ApiError> {
3030 let uid = require_uid(&session)?;
31 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
31 let issue_id = service
32 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
33 .await?;
3234 let page = service
3335 .list_members(uid, &path.space_name, path.board_id, issue_id, q)
3436 .await?;
Modified lib/issues/src/handlers/reactions/add_comment_reaction.rs View lib/issues/src/handlers/reactions/add_comment_reaction.rs
@@ -30,7 +30,9 @@
3030 Json(cmd): Json<CreateReactionCmd>,
3131) -> Result<AppResponseData<ReactionDto>, ApiError> {
3232 let uid = require_uid(&session)?;
33 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
33 let issue_id = service
34 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
35 .await?;
3436 let dto = service
3537 .create_comment_reaction(
3638 uid,
Modified lib/issues/src/handlers/reactions/add_reaction.rs View lib/issues/src/handlers/reactions/add_reaction.rs
@@ -31,7 +31,9 @@
3131 Json(cmd): Json<CreateReactionCmd>,
3232) -> Result<AppResponseData<ReactionDto>, ApiError> {
3333 let uid = require_uid(&session)?;
34 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
34 let issue_id = service
35 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
36 .await?;
3537 let dto = service
3638 .create_reaction(uid, &path.space_name, path.board_id, issue_id, cmd)
3739 .await?;
Modified lib/issues/src/handlers/reactions/list_comment_reactions.rs View lib/issues/src/handlers/reactions/list_comment_reactions.rs
@@ -26,7 +26,9 @@
2626 Path(path): Path<CommentReactionPath>,
2727) -> Result<AppResponseData<Vec<ReactionAggDto>>, ApiError> {
2828 let uid = require_uid(&session)?;
29 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
29 let issue_id = service
30 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
31 .await?;
3032 let list = service
3133 .list_comment_reactions(
3234 uid,
Modified lib/issues/src/handlers/reactions/list_reactions.rs View lib/issues/src/handlers/reactions/list_reactions.rs
@@ -27,7 +27,9 @@
2727 Path(path): Path<IssuePath>,
2828) -> Result<AppResponseData<Vec<ReactionAggDto>>, ApiError> {
2929 let uid = require_uid(&session)?;
30 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
30 let issue_id = service
31 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
32 .await?;
3133 let list = service
3234 .list_reactions(uid, &path.space_name, path.board_id, issue_id)
3335 .await?;
Modified lib/issues/src/handlers/reactions/remove_comment_reaction.rs View lib/issues/src/handlers/reactions/remove_comment_reaction.rs
@@ -27,7 +27,9 @@
2727 Query(q): Query<EmojiQuery>,
2828) -> Result<AppResponseData<NoData>, ApiError> {
2929 let uid = require_uid(&session)?;
30 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
30 let issue_id = service
31 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
32 .await?;
3133 service
3234 .remove_comment_reaction_by_emoji(
3335 uid,
Modified lib/issues/src/handlers/reactions/remove_reaction.rs View lib/issues/src/handlers/reactions/remove_reaction.rs
@@ -28,15 +28,11 @@
2828 Query(q): Query<EmojiQuery>,
2929) -> Result<AppResponseData<NoData>, ApiError> {
3030 let uid = require_uid(&session)?;
31 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
31 let issue_id = service
32 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
33 .await?;
3234 service
33 .remove_reaction_by_emoji(
34 uid,
35 &path.space_name,
36 path.board_id,
37 issue_id,
38 &q.emoji,
39 )
35 .remove_reaction_by_emoji(uid, &path.space_name, path.board_id, issue_id, &q.emoji)
4036 .await?;
4137 Ok(AppResponseData::ok(NoData))
4238}
Modified lib/issues/src/handlers/repo_relations/create_repo_relation.rs View lib/issues/src/handlers/repo_relations/create_repo_relation.rs
@@ -31,7 +31,9 @@
3131 Json(cmd): Json<CreateRepoRelCmd>,
3232) -> Result<AppResponseData<RepoRelDto>, ApiError> {
3333 let uid = require_uid(&session)?;
34 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
34 let issue_id = service
35 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
36 .await?;
3537 let dto = service
3638 .create_repo_relation(uid, &path.space_name, path.board_id, issue_id, cmd)
3739 .await?;
Modified lib/issues/src/handlers/repo_relations/delete_repo_relation.rs View lib/issues/src/handlers/repo_relations/delete_repo_relation.rs
@@ -26,15 +26,11 @@
2626 Path(path): Path<RepoRelPath>,
2727) -> Result<AppResponseData<NoData>, ApiError> {
2828 let uid = require_uid(&session)?;
29 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
29 let issue_id = service
30 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
31 .await?;
3032 service
31 .delete_repo_relation(
32 uid,
33 &path.space_name,
34 path.board_id,
35 issue_id,
36 path.rel_id,
37 )
33 .delete_repo_relation(uid, &path.space_name, path.board_id, issue_id, path.rel_id)
3834 .await?;
3935 Ok(AppResponseData::ok(NoData))
4036}
Modified lib/issues/src/handlers/repo_relations/list_repo_relations.rs View lib/issues/src/handlers/repo_relations/list_repo_relations.rs
@@ -27,7 +27,9 @@
2727 Path(path): Path<IssuePath>,
2828) -> Result<AppResponseData<Vec<RepoRelDto>>, ApiError> {
2929 let uid = require_uid(&session)?;
30 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
30 let issue_id = service
31 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
32 .await?;
3133 let list = service
3234 .list_repo_relations(uid, &path.space_name, path.board_id, issue_id)
3335 .await?;
Modified lib/issues/src/handlers/stopwatch/get_stopwatch.rs View lib/issues/src/handlers/stopwatch/get_stopwatch.rs
@@ -26,7 +26,9 @@
2626 Path(path): Path<StopwatchPath>,
2727) -> Result<AppResponseData<StopwatchDto>, ApiError> {
2828 let uid = require_uid(&session)?;
29 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
29 let issue_id = service
30 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
31 .await?;
3032 let dto = service
3133 .get_stopwatch(uid, &path.space_name, path.board_id, issue_id)
3234 .await?;
Modified lib/issues/src/handlers/stopwatch/start_stopwatch.rs View lib/issues/src/handlers/stopwatch/start_stopwatch.rs
@@ -27,7 +27,9 @@
2727 Path(path): Path<StopwatchPath>,
2828) -> Result<AppResponseData<StopwatchDto>, ApiError> {
2929 let uid = require_uid(&session)?;
30 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
30 let issue_id = service
31 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
32 .await?;
3133 let dto = service
3234 .start_stopwatch(uid, &path.space_name, path.board_id, issue_id)
3335 .await?;
Modified lib/issues/src/handlers/stopwatch/stop_stopwatch.rs View lib/issues/src/handlers/stopwatch/stop_stopwatch.rs
@@ -27,7 +27,9 @@
2727 Path(path): Path<StopwatchPath>,
2828) -> Result<AppResponseData<NoData>, ApiError> {
2929 let uid = require_uid(&session)?;
30 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
30 let issue_id = service
31 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
32 .await?;
3133 service
3234 .stop_stopwatch(uid, &path.space_name, path.board_id, issue_id)
3335 .await?;
Modified lib/issues/src/handlers/tracked_time/list_tracked_time.rs View lib/issues/src/handlers/tracked_time/list_tracked_time.rs
@@ -27,7 +27,9 @@
2727 Query(q): Query<ListTrackedTimeQuery>,
2828) -> Result<AppResponseData<TrackedTimePage>, ApiError> {
2929 let uid = require_uid(&session)?;
30 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
30 let issue_id = service
31 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
32 .await?;
3133 let page = service
3234 .list_tracked_time(uid, &path.space_name, path.board_id, issue_id, q)
3335 .await?;
Modified lib/issues/src/handlers/tracked_time/mod.rs View lib/issues/src/handlers/tracked_time/mod.rs
@@ -1,4 +1,4 @@
1pub mod create_tracked_time;
2pub mod delete_tracked_time;
13pub mod list_tracked_time;
2pub mod create_tracked_time;
34pub mod update_tracked_time;
4pub mod delete_tracked_time;
Modified lib/issues/src/handlers/watches/upsert_watch.rs View lib/issues/src/handlers/watches/upsert_watch.rs
@@ -29,7 +29,9 @@
2929 Json(cmd): Json<UpsertWatchCmd>,
3030) -> Result<AppResponseData<WatchDto>, ApiError> {
3131 let uid = require_uid(&session)?;
32 let issue_id = service.resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id).await?;
32 let issue_id = service
33 .resolve_issue_ref(uid, &path.space_name, path.board_id, &path.issue_id)
34 .await?;
3335 let dto = service
3436 .upsert_watch(uid, &path.space_name, path.board_id, issue_id, cmd)
3537 .await?;
Modified lib/issues/src/services/boards.rs View lib/issues/src/services/boards.rs
@@ -300,7 +300,12 @@
300300 })
301301 .collect();
302302
303 Ok(BoardPage { items, total, limit, offset })
303 Ok(BoardPage {
304 items,
305 total,
306 limit,
307 offset,
308 })
304309 }
305310
306311 pub async fn get_board(
Modified lib/issues/src/services/content_history.rs View lib/issues/src/services/content_history.rs
@@ -8,7 +8,7 @@
88
99use crate::errors::IssueError;
1010use crate::services::service::IssueService;
11use crate::share::lookup::{require_issue_access};
11use crate::share::lookup::require_issue_access;
1212
1313#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
1414pub struct ContentHistoryDto {
@@ -87,6 +87,11 @@
8787 })
8888 .collect();
8989
90 Ok(ContentHistoryPage { items, total, limit, offset })
90 Ok(ContentHistoryPage {
91 items,
92 total,
93 limit,
94 offset,
95 })
9196 }
9297}
Modified lib/issues/src/services/dependencies.rs View lib/issues/src/services/dependencies.rs
@@ -1,5 +1,5 @@
1use models::issue::issue_dependency;
12use models::issue::issue_event_line::IssueEventType;
2use models::issue::issue_dependency;
33use sea_orm::{
44 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait,
55};
Modified lib/issues/src/services/events.rs View lib/issues/src/services/events.rs
@@ -8,7 +8,7 @@
88
99use crate::errors::IssueError;
1010use crate::services::service::IssueService;
11use crate::share::lookup::{require_issue_access};
11use crate::share::lookup::require_issue_access;
1212
1313#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
1414pub struct EventDto {
@@ -89,6 +89,11 @@
8989 })
9090 .collect();
9191
92 Ok(EventPage { items, total, limit, offset })
92 Ok(EventPage {
93 items,
94 total,
95 limit,
96 offset,
97 })
9398 }
9499}
Modified lib/issues/src/services/issue_users.rs View lib/issues/src/services/issue_users.rs
@@ -11,7 +11,7 @@
1111
1212use crate::errors::IssueError;
1313use crate::services::service::IssueService;
14use crate::share::lookup::{require_issue_access};
14use crate::share::lookup::require_issue_access;
1515
1616#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
1717pub struct IssueUserDto {
@@ -87,7 +87,12 @@
8787 })
8888 .collect();
8989
90 Ok(IssueUserPage { items, total, limit, offset })
90 Ok(IssueUserPage {
91 items,
92 total,
93 limit,
94 offset,
95 })
9196 }
9297
9398 pub async fn update_issue_user(
Modified lib/issues/src/services/members.rs View lib/issues/src/services/members.rs
@@ -239,7 +239,12 @@
239239 })
240240 .collect();
241241
242 Ok(IssueMemberPage { items, total, limit, offset })
242 Ok(IssueMemberPage {
243 items,
244 total,
245 limit,
246 offset,
247 })
243248 }
244249
245250 pub async fn delete_member(
Modified lib/issues/src/services/milestones.rs View lib/issues/src/services/milestones.rs
@@ -244,7 +244,12 @@
244244 })
245245 .collect();
246246
247 Ok(MilestonePage { items, total, limit, offset })
247 Ok(MilestonePage {
248 items,
249 total,
250 limit,
251 offset,
252 })
248253 }
249254
250255 pub async fn get_milestone(
Modified lib/issues/src/services/stopwatch.rs View lib/issues/src/services/stopwatch.rs
@@ -9,7 +9,7 @@
99
1010use crate::errors::IssueError;
1111use crate::services::service::IssueService;
12use crate::share::lookup::{require_issue_access};
12use crate::share::lookup::require_issue_access;
1313
1414#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
1515pub struct StopwatchDto {
Modified lib/issues/src/services/tracked_time.rs View lib/issues/src/services/tracked_time.rs
@@ -1,5 +1,8 @@
11use models::issue::tracked_time;
2use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set};
2use sea_orm::{
3 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
4 QuerySelect, Set,
5};
36use serde::{Deserialize, Serialize};
47use space::services::spaces::UserDto;
58use time::OffsetDateTime;
@@ -8,7 +11,7 @@
811
912use crate::errors::IssueError;
1013use crate::services::service::IssueService;
11use crate::share::lookup::{require_issue_access};
14use crate::share::lookup::require_issue_access;
1215
1316#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
1417pub struct TrackedTimeDto {
@@ -63,7 +66,9 @@
6366 cmd: CreateTrackedTimeCmd,
6467 ) -> Result<TrackedTimeDto, IssueError> {
6568 if cmd.seconds <= 0 {
66 return Err(IssueError::BadRequest("seconds must be positive".to_string()));
69 return Err(IssueError::BadRequest(
70 "seconds must be positive".to_string(),
71 ));
6772 }
6873 require_issue_access(self, caller, space_name, board_id, issue_id).await?;
6974 let row = tracked_time::ActiveModel {
@@ -95,7 +100,9 @@
95100 cmd: UpdateTrackedTimeCmd,
96101 ) -> Result<TrackedTimeDto, IssueError> {
97102 if cmd.seconds <= 0 {
98 return Err(IssueError::BadRequest("seconds must be positive".to_string()));
103 return Err(IssueError::BadRequest(
104 "seconds must be positive".to_string(),
105 ));
99106 }
100107 require_issue_access(self, caller, space_name, board_id, issue_id).await?;
101108 let row = tracked_time::Entity::find_by_id(time_id)
@@ -187,6 +194,11 @@
187194 })
188195 .collect();
189196
190 Ok(TrackedTimePage { items, total, limit, offset })
197 Ok(TrackedTimePage {
198 items,
199 total,
200 limit,
201 offset,
202 })
191203 }
192204}
Modified lib/issues/src/services/watches.rs View lib/issues/src/services/watches.rs
@@ -9,7 +9,7 @@
99
1010use crate::errors::IssueError;
1111use crate::services::service::IssueService;
12use crate::share::lookup::{require_issue_access};
12use crate::share::lookup::require_issue_access;
1313
1414#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
1515pub struct WatchDto {
Modified lib/models/src/admin/admin_audit_log.rs View lib/models/src/admin/admin_audit_log.rs
@@ -25,4 +25,4 @@
2525 pub created_at: OffsetDateTime,
2626}
2727
28impl ActiveModelBehavior for ActiveModel {}
No newline at end of file
28impl ActiveModelBehavior for ActiveModel {}
Modified lib/models/src/admin/admin_user.rs View lib/models/src/admin/admin_user.rs
@@ -51,4 +51,4 @@
5151 pub updated_at: OffsetDateTime,
5252}
5353
54impl ActiveModelBehavior for ActiveModel {}
No newline at end of file
54impl ActiveModelBehavior for ActiveModel {}
Modified lib/models/src/admin/mod.rs View lib/models/src/admin/mod.rs
@@ -1,2 +1,2 @@
11pub mod admin_audit_log;
2pub mod admin_user;
No newline at end of file
2pub mod admin_user;
Renamed lib/models/src/repo/pipeline_run.rs → lib/models/src/ci/ci_deployment.rs View lib/models/src/ci/ci_deployment.rs
@@ -18,10 +18,10 @@
1818)]
1919#[serde(rename_all = "snake_case")]
2020#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
21pub enum PipelineRunState {
21pub enum DeploymentState {
2222 #[default]
2323 #[sea_orm(num_value = 0)]
24 Queued,
24 Pending,
2525 #[sea_orm(num_value = 1)]
2626 Running,
2727 #[sea_orm(num_value = 2)]
@@ -29,30 +29,34 @@
2929 #[sea_orm(num_value = 3)]
3030 Failure,
3131 #[sea_orm(num_value = 4)]
32 CancelRequested,
33 #[sea_orm(num_value = 5)]
3432 Cancelled,
3533}
3634
3735#[sea_orm::model]
3836#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
39#[sea_orm(table_name = "pipeline_run")]
37#[sea_orm(table_name = "ci_deployment")]
4038pub struct Model {
4139 #[sea_orm(primary_key, auto_increment = false)]
4240 pub id: Uuid,
4341 #[sea_orm(indexed)]
42 pub environment_id: Uuid,
43 pub run_id: Uuid,
44 pub job_id: Uuid,
4445 pub repo_id: Uuid,
45 pub trigger_user_id: Uuid,
4646 pub commit_sha: String,
47 pub branch: String,
48 #[sea_orm(default_value = 0)]
49 pub state: PipelineRunState,
50 pub started_at: Option<OffsetDateTime>,
51 pub finished_at: Option<OffsetDateTime>,
47 pub git_ref: String,
48 pub state: DeploymentState,
49 pub actor_id: Option<Uuid>,
5250 pub created_at: OffsetDateTime,
5351 pub updated_at: OffsetDateTime,
52 #[sea_orm(belongs_to, from = "environment_id", to = "id")]
53 pub environment: BelongsTo<super::ci_environment::Entity>,
54 #[sea_orm(belongs_to, from = "run_id", to = "id")]
55 pub run: BelongsTo<super::workflow_run::Entity>,
56 #[sea_orm(belongs_to, from = "job_id", to = "id")]
57 pub job: BelongsTo<super::workflow_job::Entity>,
5458 #[sea_orm(belongs_to, from = "repo_id", to = "id")]
55 pub repo: BelongsTo<super::repos::Entity>,
59 pub repo: BelongsTo<super::super::repo::repos::Entity>,
5660}
5761
5862impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/ci/ci_environment.rs View lib/models/src/ci/ci_environment.rs
@@ -0,0 +1,23 @@
1use sea_orm::JsonValue;
2use sea_orm::entity::prelude::*;
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6#[sea_orm::model]
7#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
8#[sea_orm(table_name = "ci_environment")]
9pub struct Model {
10 #[sea_orm(primary_key, auto_increment = false)]
11 pub id: Uuid,
12 pub space_id: Uuid,
13 pub name: String,
14 pub description: Option<String>,
15 #[sea_orm(column_type = "JsonBinary")]
16 pub required_labels: JsonValue,
17 pub created_at: OffsetDateTime,
18 pub updated_at: OffsetDateTime,
19 #[sea_orm(belongs_to, from = "space_id", to = "id")]
20 pub space: BelongsTo<super::super::space::spaces::Entity>,
21}
22
23impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/ci/ci_log_chunk.rs View lib/models/src/ci/ci_log_chunk.rs
@@ -0,0 +1,21 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "ci_log_chunk")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub id: Uuid,
11 #[sea_orm(indexed)]
12 pub job_id: Uuid,
13 pub seq: i32,
14 pub object_key: String,
15 pub size: i64,
16 pub created_at: OffsetDateTime,
17 #[sea_orm(belongs_to, from = "job_id", to = "id")]
18 pub job: BelongsTo<super::workflow_job::Entity>,
19}
20
21impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/ci/ci_secret.rs View lib/models/src/ci/ci_secret.rs
@@ -0,0 +1,54 @@
1use sea_orm::entity::prelude::*;
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6#[derive(
7 Debug,
8 Clone,
9 Copy,
10 PartialEq,
11 Eq,
12 Default,
13 EnumIter,
14 DeriveActiveEnum,
15 Serialize,
16 Deserialize,
17 utoipa::ToSchema,
18)]
19#[serde(rename_all = "snake_case")]
20#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
21pub enum CiSecretScope {
22 #[default]
23 #[sea_orm(num_value = 0)]
24 Repo,
25 #[sea_orm(num_value = 1)]
26 Space,
27 #[sea_orm(num_value = 2)]
28 Environment,
29}
30
31#[sea_orm::model]
32#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
33#[sea_orm(table_name = "ci_secret")]
34pub struct Model {
35 #[sea_orm(primary_key, auto_increment = false)]
36 pub id: Uuid,
37 pub scope: CiSecretScope,
38 pub repo_id: Option<Uuid>,
39 pub space_id: Option<Uuid>,
40 pub environment_id: Option<Uuid>,
41 pub name: String,
42 pub value_ciphertext: String,
43 pub created_by: Option<Uuid>,
44 pub created_at: OffsetDateTime,
45 pub updated_at: OffsetDateTime,
46 #[sea_orm(belongs_to, from = "repo_id", to = "id")]
47 pub repo: BelongsTo<Option<super::super::repo::repos::Entity>>,
48 #[sea_orm(belongs_to, from = "space_id", to = "id")]
49 pub space: BelongsTo<Option<super::super::space::spaces::Entity>>,
50 #[sea_orm(belongs_to, from = "environment_id", to = "id")]
51 pub environment: BelongsTo<Option<super::ci_environment::Entity>>,
52}
53
54impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/ci/mod.rs View lib/models/src/ci/mod.rs
@@ -0,0 +1,10 @@
1pub mod ci_deployment;
2pub mod ci_environment;
3pub mod ci_log_chunk;
4pub mod ci_secret;
5pub mod runner;
6pub mod runner_token;
7pub mod workflow_artifact;
8pub mod workflow_job;
9pub mod workflow_run;
10pub mod workflow_step;
Added lib/models/src/ci/runner.rs View lib/models/src/ci/runner.rs
@@ -0,0 +1,51 @@
1use sea_orm::JsonValue;
2use sea_orm::entity::prelude::*;
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use uuid::Uuid;
6
7#[derive(
8 Debug,
9 Clone,
10 Copy,
11 PartialEq,
12 Eq,
13 Default,
14 EnumIter,
15 DeriveActiveEnum,
16 Serialize,
17 Deserialize,
18 utoipa::ToSchema,
19)]
20#[serde(rename_all = "snake_case")]
21#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
22pub enum RunnerState {
23 #[default]
24 #[sea_orm(num_value = 0)]
25 Active,
26 #[sea_orm(num_value = 1)]
27 Disabled,
28}
29
30#[sea_orm::model]
31#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
32#[sea_orm(table_name = "ci_runner")]
33pub struct Model {
34 #[sea_orm(primary_key, auto_increment = false)]
35 pub id: Uuid,
36 pub name: String,
37 #[sea_orm(column_type = "JsonBinary")]
38 pub labels: JsonValue,
39 #[sea_orm(default_value = 1)]
40 pub capacity: i32,
41 pub version: Option<String>,
42 pub state: RunnerState,
43 pub space_id: Option<Uuid>,
44 pub last_seen_at: Option<OffsetDateTime>,
45 pub created_at: OffsetDateTime,
46 pub updated_at: OffsetDateTime,
47 #[sea_orm(belongs_to, from = "space_id", to = "id")]
48 pub space: BelongsTo<Option<super::super::space::spaces::Entity>>,
49}
50
51impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/ci/runner_token.rs View lib/models/src/ci/runner_token.rs
@@ -0,0 +1,30 @@
1use sea_orm::entity::prelude::*;
2use time::OffsetDateTime;
3use uuid::Uuid;
4
5#[sea_orm::model]
6#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
7#[sea_orm(table_name = "ci_runner_token")]
8pub struct Model {
9 #[sea_orm(primary_key, auto_increment = false)]
10 pub id: Uuid,
11 pub runner_id: Option<Uuid>,
12 pub name: String,
13 #[sea_orm(unique)]
14 pub token_hash: String,
15 pub token_salt: String,
16 pub token_last_eight: String,
17 pub space_id: Option<Uuid>,
18 pub single_use: bool,
19 pub used_at: Option<OffsetDateTime>,
20 pub revoked_at: Option<OffsetDateTime>,
21 pub expires_at: Option<OffsetDateTime>,
22 pub created_at: OffsetDateTime,
23 pub updated_at: OffsetDateTime,
24 #[sea_orm(belongs_to, from = "runner_id", to = "id")]
25 pub runner: BelongsTo<Option<super::runner::Entity>>,
26 #[sea_orm(belongs_to, from = "space_id", to = "id")]
27 pub space: BelongsTo<Option<super::super::space::spaces::Entity>>,
28}
29
30impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/ci/workflow_artifact.rs View lib/models/src/ci/workflow_artifact.rs
@@ -0,0 +1,56 @@
1use sea_orm::entity::prelude::*;
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6#[derive(
7 Debug,
8 Clone,
9 Copy,
10 PartialEq,
11 Eq,
12 Default,
13 EnumIter,
14 DeriveActiveEnum,
15 Serialize,
16 Deserialize,
17 utoipa::ToSchema,
18)]
19#[serde(rename_all = "snake_case")]
20#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
21pub enum ArtifactState {
22 #[default]
23 #[sea_orm(num_value = 0)]
24 Pending,
25 #[sea_orm(num_value = 1)]
26 Uploading,
27 #[sea_orm(num_value = 2)]
28 Ready,
29 #[sea_orm(num_value = 3)]
30 Failed,
31}
32
33#[sea_orm::model]
34#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
35#[sea_orm(table_name = "workflow_artifact")]
36pub struct Model {
37 #[sea_orm(primary_key, auto_increment = false)]
38 pub id: Uuid,
39 #[sea_orm(indexed)]
40 pub run_id: Uuid,
41 pub job_id: Uuid,
42 pub name: String,
43 pub object_key: String,
44 pub size: i64,
45 pub sha256: Option<String>,
46 pub state: ArtifactState,
47 pub expires_at: Option<OffsetDateTime>,
48 pub created_at: OffsetDateTime,
49 pub updated_at: OffsetDateTime,
50 #[sea_orm(belongs_to, from = "run_id", to = "id")]
51 pub run: BelongsTo<super::workflow_run::Entity>,
52 #[sea_orm(belongs_to, from = "job_id", to = "id")]
53 pub job: BelongsTo<super::workflow_job::Entity>,
54}
55
56impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/ci/workflow_job.rs View lib/models/src/ci/workflow_job.rs
@@ -0,0 +1,115 @@
1use sea_orm::JsonValue;
2use sea_orm::entity::prelude::*;
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use uuid::Uuid;
6
7#[derive(
8 Debug,
9 Clone,
10 Copy,
11 PartialEq,
12 Eq,
13 Default,
14 EnumIter,
15 DeriveActiveEnum,
16 Serialize,
17 Deserialize,
18 utoipa::ToSchema,
19)]
20#[serde(rename_all = "snake_case")]
21#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
22pub enum WorkflowJobState {
23 #[default]
24 #[sea_orm(num_value = 0)]
25 Pending,
26 #[sea_orm(num_value = 1)]
27 Queued,
28 #[sea_orm(num_value = 2)]
29 Claimed,
30 #[sea_orm(num_value = 3)]
31 Running,
32 #[sea_orm(num_value = 4)]
33 Success,
34 #[sea_orm(num_value = 5)]
35 Failure,
36 #[sea_orm(num_value = 6)]
37 TimedOut,
38 #[sea_orm(num_value = 7)]
39 Cancelled,
40 #[sea_orm(num_value = 8)]
41 Skipped,
42}
43
44impl WorkflowJobState {
45 #[must_use]
46 pub const fn is_terminal(self) -> bool {
47 matches!(
48 self,
49 Self::Success | Self::Failure | Self::TimedOut | Self::Cancelled | Self::Skipped
50 )
51 }
52
53 #[must_use]
54 pub const fn is_active(self) -> bool {
55 matches!(self, Self::Claimed | Self::Running)
56 }
57
58 #[must_use]
59 pub const fn as_str(self) -> &'static str {
60 match self {
61 Self::Pending => "pending",
62 Self::Queued => "queued",
63 Self::Claimed => "claimed",
64 Self::Running => "running",
65 Self::Success => "success",
66 Self::Failure => "failure",
67 Self::TimedOut => "timed_out",
68 Self::Cancelled => "cancelled",
69 Self::Skipped => "skipped",
70 }
71 }
72}
73
74#[sea_orm::model]
75#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
76#[sea_orm(table_name = "workflow_job")]
77pub struct Model {
78 #[sea_orm(primary_key, auto_increment = false)]
79 pub id: Uuid,
80 #[sea_orm(indexed)]
81 pub run_id: Uuid,
82 pub repo_id: Uuid,
83 pub space_id: Uuid,
84 pub name: String,
85 pub job_key: String,
86 #[sea_orm(column_type = "JsonBinary")]
87 pub definition: JsonValue,
88 #[sea_orm(column_type = "JsonBinary")]
89 pub runs_on: JsonValue,
90 pub environment: Option<String>,
91 #[sea_orm(column_type = "JsonBinary")]
92 pub needs: JsonValue,
93 #[sea_orm(default_value = 0)]
94 pub state: WorkflowJobState,
95 pub runner_id: Option<Uuid>,
96 pub lease_token: Option<String>,
97 pub lease_expires_at: Option<OffsetDateTime>,
98 pub attempt: i32,
99 pub exit_code: Option<i32>,
100 pub error: Option<String>,
101 pub started_at: Option<OffsetDateTime>,
102 pub finished_at: Option<OffsetDateTime>,
103 pub created_at: OffsetDateTime,
104 pub updated_at: OffsetDateTime,
105 #[sea_orm(belongs_to, from = "run_id", to = "id")]
106 pub run: BelongsTo<super::workflow_run::Entity>,
107 #[sea_orm(belongs_to, from = "repo_id", to = "id")]
108 pub repo: BelongsTo<super::super::repo::repos::Entity>,
109 #[sea_orm(belongs_to, from = "space_id", to = "id")]
110 pub space: BelongsTo<super::super::space::spaces::Entity>,
111 #[sea_orm(belongs_to, from = "runner_id", to = "id")]
112 pub runner: BelongsTo<Option<super::runner::Entity>>,
113}
114
115impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/ci/workflow_run.rs View lib/models/src/ci/workflow_run.rs
@@ -0,0 +1,110 @@
1use sea_orm::JsonValue;
2use sea_orm::entity::prelude::*;
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use uuid::Uuid;
6
7#[derive(
8 Debug,
9 Clone,
10 Copy,
11 PartialEq,
12 Eq,
13 Default,
14 EnumIter,
15 DeriveActiveEnum,
16 Serialize,
17 Deserialize,
18 utoipa::ToSchema,
19)]
20#[serde(rename_all = "snake_case")]
21#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
22pub enum WorkflowRunState {
23 #[default]
24 #[sea_orm(num_value = 0)]
25 Queued,
26 #[sea_orm(num_value = 1)]
27 Running,
28 #[sea_orm(num_value = 2)]
29 Success,
30 #[sea_orm(num_value = 3)]
31 Failure,
32 #[sea_orm(num_value = 4)]
33 CancelRequested,
34 #[sea_orm(num_value = 5)]
35 Cancelled,
36}
37
38#[derive(
39 Debug,
40 Clone,
41 Copy,
42 PartialEq,
43 Eq,
44 Default,
45 EnumIter,
46 DeriveActiveEnum,
47 Serialize,
48 Deserialize,
49 utoipa::ToSchema,
50)]
51#[serde(rename_all = "snake_case")]
52#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
53pub enum WorkflowEventKind {
54 #[default]
55 #[sea_orm(num_value = 0)]
56 Push,
57 #[sea_orm(num_value = 1)]
58 PullRequest,
59 #[sea_orm(num_value = 2)]
60 WorkflowDispatch,
61}
62
63impl WorkflowEventKind {
64 #[must_use]
65 pub const fn as_str(self) -> &'static str {
66 match self {
67 Self::Push => "push",
68 Self::PullRequest => "pull_request",
69 Self::WorkflowDispatch => "workflow_dispatch",
70 }
71 }
72}
73
74#[sea_orm::model]
75#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
76#[sea_orm(table_name = "workflow_run")]
77pub struct Model {
78 #[sea_orm(primary_key, auto_increment = false)]
79 pub id: Uuid,
80 #[sea_orm(indexed)]
81 pub repo_id: Uuid,
82 pub trigger_user_id: Option<Uuid>,
83 pub workflow_file: String,
84 pub workflow_name: String,
85 pub event: WorkflowEventKind,
86 pub event_payload: JsonValue,
87 pub definition: JsonValue,
88 pub commit_sha: String,
89 pub git_ref: String,
90 pub branch: Option<String>,
91 pub tag: Option<String>,
92 pub base_sha: Option<String>,
93 pub base_ref: Option<String>,
94 pub pr_number: Option<i64>,
95 #[sea_orm(default_value = 0)]
96 pub state: WorkflowRunState,
97 pub concurrency_group: Option<String>,
98 pub runner_id: Option<Uuid>,
99 pub attempt: i32,
100 pub started_at: Option<OffsetDateTime>,
101 pub finished_at: Option<OffsetDateTime>,
102 pub created_at: OffsetDateTime,
103 pub updated_at: OffsetDateTime,
104 #[sea_orm(belongs_to, from = "repo_id", to = "id")]
105 pub repo: BelongsTo<super::super::repo::repos::Entity>,
106 #[sea_orm(belongs_to, from = "trigger_user_id", to = "uid")]
107 pub trigger_user: BelongsTo<Option<super::super::users::user::Entity>>,
108}
109
110impl ActiveModelBehavior for ActiveModel {}
Added lib/models/src/ci/workflow_step.rs View lib/models/src/ci/workflow_step.rs
@@ -0,0 +1,60 @@
1use sea_orm::entity::prelude::*;
2use serde::{Deserialize, Serialize};
3use time::OffsetDateTime;
4use uuid::Uuid;
5
6#[derive(
7 Debug,
8 Clone,
9 Copy,
10 PartialEq,
11 Eq,
12 Default,
13 EnumIter,
14 DeriveActiveEnum,
15 Serialize,
16 Deserialize,
17 utoipa::ToSchema,
18)]
19#[serde(rename_all = "snake_case")]
20#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
21pub enum WorkflowStepState {
22 #[default]
23 #[sea_orm(num_value = 0)]
24 Pending,
25 #[sea_orm(num_value = 1)]
26 Running,
27 #[sea_orm(num_value = 2)]
28 Success,
29 #[sea_orm(num_value = 3)]
30 Failure,
31 #[sea_orm(num_value = 4)]
32 Skipped,
33 #[sea_orm(num_value = 5)]
34 Cancelled,
35}
36
37#[sea_orm::model]
38#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
39#[sea_orm(table_name = "workflow_step")]
40pub struct Model {
41 #[sea_orm(primary_key, auto_increment = false)]
42 pub id: Uuid,
43 #[sea_orm(indexed)]
44 pub job_id: Uuid,
45 pub step_index: i32,
46 pub name: String,
47 pub script: String,
48 pub shell: Option<String>,
49 pub working_directory: Option<String>,
50 pub state: WorkflowStepState,
51 pub exit_code: Option<i32>,
52 pub started_at: Option<OffsetDateTime>,
53 pub finished_at: Option<OffsetDateTime>,
54 pub created_at: OffsetDateTime,
55 pub updated_at: OffsetDateTime,
56 #[sea_orm(belongs_to, from = "job_id", to = "id")]
57 pub job: BelongsTo<super::workflow_job::Entity>,
58}
59
60impl ActiveModelBehavior for ActiveModel {}
Modified lib/models/src/repo/mod.rs View lib/models/src/repo/mod.rs
@@ -8,5 +8,4 @@
88pub mod repo_tag_protection_whitelists;
99pub mod repo_topic;
1010pub mod repo_watch;
11pub mod pipeline_run;
1211pub mod repos;
Modified lib/models/src/repo/repo_branch_protection_policy.rs View lib/models/src/repo/repo_branch_protection_policy.rs
@@ -24,6 +24,8 @@
2424 pub protected_file_patterns: String,
2525 pub unprotected_file_patterns: String,
2626 pub enable_force_push: bool,
27 #[sea_orm(default_value = "")]
28 pub required_status_contexts: String,
2729 pub created_at: OffsetDateTime,
2830 pub updated_at: OffsetDateTime,
2931 #[sea_orm(belongs_to, from = "repo_id", to = "id")]
Modified lib/models/src/room/mod.rs View lib/models/src/room/mod.rs
@@ -1,5 +1,5 @@
1pub mod room_bans;
12pub mod room_invites;
2pub mod room_bans;
33pub mod room_members;
44pub mod rooms;
55
Modified lib/models/src/users/queries.rs View lib/models/src/users/queries.rs
@@ -20,5 +20,8 @@
2020 .filter(user_emails::Column::DeletedAt.is_null())
2121 .all(db)
2222 .await?;
23 Ok(rows.into_iter().map(|r| (r.user_id, r.lower_email)).collect())
23 Ok(rows
24 .into_iter()
25 .map(|r| (r.user_id, r.lower_email))
26 .collect())
2427}
Modified lib/models/src/webhook/mod.rs View lib/models/src/webhook/mod.rs
@@ -1,3 +1,4 @@
1pub mod repo_webhook_delivery;
12pub mod repo_webhooks;
23pub mod webhook_deliveries;
34pub mod webhooks;
Added lib/models/src/webhook/repo_webhook_delivery.rs View lib/models/src/webhook/repo_webhook_delivery.rs
@@ -0,0 +1,51 @@
1use sea_orm::JsonValue;
2use sea_orm::entity::prelude::*;
3use serde::{Deserialize, Serialize};
4use time::OffsetDateTime;
5use uuid::Uuid;
6
7#[derive(
8 Debug, Clone, Copy, PartialEq, Eq, Default, EnumIter, DeriveActiveEnum, Serialize, Deserialize,
9)]
10#[serde(rename_all = "snake_case")]
11#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
12pub enum RepoWebhookDeliveryStatus {
13 #[default]
14 #[sea_orm(num_value = 0)]
15 Pending,
16 #[sea_orm(num_value = 1)]
17 Delivering,
18 #[sea_orm(num_value = 2)]
19 Done,
20 #[sea_orm(num_value = 3)]
21 Failed,
22}
23
24#[sea_orm::model]
25#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
26#[sea_orm(table_name = "repo_webhook_delivery")]
27pub struct Model {
28 #[sea_orm(primary_key, auto_increment = false)]
29 pub id: Uuid,
30 #[sea_orm(indexed)]
31 pub webhook_id: Uuid,
32 pub repo_id: Uuid,
33 pub event_type: String,
34 #[sea_orm(column_type = "JsonBinary")]
35 pub payload: JsonValue,
36 pub status: RepoWebhookDeliveryStatus,
37 #[sea_orm(default_value = 0)]
38 pub attempts: i32,
39 pub next_retry_at: OffsetDateTime,
40 pub response_status: Option<i32>,
41 pub response_body: Option<String>,
42 pub last_error: Option<String>,
43 pub created_at: OffsetDateTime,
44 pub updated_at: OffsetDateTime,
45 #[sea_orm(belongs_to, from = "webhook_id", to = "id")]
46 pub webhook: BelongsTo<super::repo_webhooks::Entity>,
47 #[sea_orm(belongs_to, from = "repo_id", to = "id")]
48 pub repo: BelongsTo<super::super::repo::repos::Entity>,
49}
50
51impl ActiveModelBehavior for ActiveModel {}
Modified lib/models/src/lib.rs View lib/models/src/lib.rs
@@ -1,5 +1,6 @@
11pub mod admin;
22pub mod chat;
3pub mod ci;
34pub mod common;
45pub mod dir;
56pub mod git;
Modified lib/models/schema_extras.sql View lib/models/schema_extras.sql
@@ -196,8 +196,32 @@
196196UPDATE issue_watch SET mode = 0 WHERE mode = 1;
197197UPDATE room_members rm SET role = 3
198198 FROM rooms r WHERE rm.room_id = r.id AND rm.user_id = r.owner_id AND rm.left_at IS NULL;
199CREATE INDEX IF NOT EXISTS idx_pipeline_run_repo
200 ON pipeline_run (repo_id, created_at);
199CREATE INDEX IF NOT EXISTS idx_workflow_run_repo
200 ON workflow_run (repo_id, created_at DESC);
201CREATE INDEX IF NOT EXISTS idx_workflow_run_concurrency
202 ON workflow_run (repo_id, concurrency_group) WHERE concurrency_group IS NOT NULL;
203CREATE INDEX IF NOT EXISTS idx_workflow_run_commit
204 ON workflow_run (repo_id, commit_sha);
205CREATE INDEX IF NOT EXISTS idx_workflow_job_claim
206 ON workflow_job (state, created_at);
207CREATE INDEX IF NOT EXISTS idx_workflow_job_lease
208 ON workflow_job (lease_expires_at) WHERE lease_expires_at IS NOT NULL;
209CREATE INDEX IF NOT EXISTS idx_workflow_step_job
210 ON workflow_step (job_id, step_index);
211CREATE INDEX IF NOT EXISTS idx_ci_log_chunk_job
212 ON ci_log_chunk (job_id, seq);
213CREATE INDEX IF NOT EXISTS idx_ci_secret_repo
214 ON ci_secret (repo_id, name) WHERE repo_id IS NOT NULL;
215CREATE INDEX IF NOT EXISTS idx_ci_secret_space
216 ON ci_secret (space_id, name) WHERE space_id IS NOT NULL;
217CREATE INDEX IF NOT EXISTS idx_ci_secret_environment
218 ON ci_secret (environment_id, name) WHERE environment_id IS NOT NULL;
219CREATE INDEX IF NOT EXISTS idx_ci_environment_space
220 ON ci_environment (space_id, name);
221CREATE INDEX IF NOT EXISTS idx_ci_deployment_environment
222 ON ci_deployment (environment_id, created_at DESC);
223CREATE INDEX IF NOT EXISTS idx_repo_webhook_delivery_due
224 ON repo_webhook_delivery (status, next_retry_at);
201225
202226UPDATE user_access_key SET scope = 'write' WHERE scope IN ('repo', 'all');
203227UPDATE user_access_key SET scope = 'read' WHERE scope NOT IN ('read', 'write');
@@ -268,4 +292,7 @@
268292-- ---------------------------------------------------------------------------
269293CREATE INDEX IF NOT EXISTS idx_admin_audit_log_created ON admin_audit_log (created_at DESC);
270294CREATE INDEX IF NOT EXISTS idx_admin_audit_log_admin ON admin_audit_log (admin_id, created_at DESC);
271CREATE INDEX IF NOT EXISTS idx_admin_audit_log_target ON admin_audit_log (target_type, target_id);
No newline at end of file
295CREATE INDEX IF NOT EXISTS idx_admin_audit_log_target ON admin_audit_log (target_type, target_id);
296
297CREATE INDEX IF NOT EXISTS idx_ci_runner_state
298 ON ci_runner (state, last_seen_at DESC);
Modified lib/models/schema_pre_sync.sql View lib/models/schema_pre_sync.sql
@@ -20,6 +20,23 @@
2020-- scheduled messages feature removed; drop the legacy table before sync
2121DROP TABLE IF EXISTS user_scheduled_message;
2222
23-- pipeline_run was replaced by workflow_run/workflow_job/workflow_step
24DROP INDEX IF EXISTS idx_workflow_run_repo;
25DROP INDEX IF EXISTS idx_workflow_run_concurrency;
26DROP INDEX IF EXISTS idx_workflow_run_commit;
27DROP INDEX IF EXISTS idx_workflow_job_claim;
28DROP INDEX IF EXISTS idx_workflow_job_lease;
29DROP INDEX IF EXISTS idx_workflow_step_job;
30DROP INDEX IF EXISTS idx_ci_log_chunk_job;
31DROP INDEX IF EXISTS idx_ci_secret_repo;
32DROP INDEX IF EXISTS idx_ci_secret_space;
33DROP INDEX IF EXISTS idx_ci_secret_environment;
34DROP INDEX IF EXISTS idx_ci_environment_space;
35DROP INDEX IF EXISTS idx_ci_deployment_environment;
36DROP INDEX IF EXISTS idx_repo_webhook_delivery_due;
37DROP INDEX IF EXISTS idx_ci_runner_state;
38DROP TABLE IF EXISTS pipeline_run;
39
2340-- idempotent column adds for phase-2 model changes (sync layer cannot
2441-- express NOT NULL DEFAULT additions on populated tables).
2542-- Guarded with to_regclass: on a fresh database the tables do not exist yet
@@ -73,3 +90,11 @@
7390 ALTER TABLE pull_request_comment ADD COLUMN IF NOT EXISTS resolved_by UUID;
7491 END IF;
7592END $$;
93
94DO $$ BEGIN
95 IF to_regclass('repo_branch_protection_policy') IS NOT NULL THEN
96 ALTER TABLE repo_branch_protection_policy
97 ADD COLUMN IF NOT EXISTS required_status_contexts VARCHAR NOT NULL DEFAULT '';
98 END IF;
99END $$;
100
Modified lib/pulls/src/services/approvals.rs View lib/pulls/src/services/approvals.rs
@@ -232,6 +232,50 @@
232232 }
233233 }
234234
235 let required_contexts: Vec<String> = policies
236 .iter()
237 .flat_map(|policy| {
238 policy
239 .required_status_contexts
240 .split(['\n', ','])
241 .map(|value| value.trim().to_string())
242 .collect::<Vec<_>>()
243 })
244 .filter(|value| !value.is_empty())
245 .collect();
246 if !required_contexts.is_empty() {
247 let statuses = self
248 .git
249 .list_commit_statuses(
250 space_name.clone(),
251 repo_name.clone(),
252 summary.head_sha.clone(),
253 )
254 .await?;
255 for context in &required_contexts {
256 let state = statuses
257 .iter()
258 .find(|status| status.context == *context)
259 .map(|status| status.state);
260 match state {
261 Some(git::service::commits::CommitStatusDtoState::Success) => {}
262 Some(other) => reasons.push(format!(
263 "required check `{context}` is {}",
264 match other {
265 git::service::commits::CommitStatusDtoState::Pending => "pending",
266 git::service::commits::CommitStatusDtoState::Failure => "failed",
267 git::service::commits::CommitStatusDtoState::Error => "errored",
268 git::service::commits::CommitStatusDtoState::Success =>
269 "successful",
270 }
271 )),
272 None => {
273 reasons.push(format!("required check `{context}` has not reported yet"))
274 }
275 }
276 }
277 }
278
235279 let protected_raw: String = policies
236280 .iter()
237281 .map(|policy| policy.protected_file_patterns.as_str())
Modified lib/pulls/src/services/changes.rs View lib/pulls/src/services/changes.rs
@@ -154,6 +154,8 @@
154154 None,
155155 )
156156 .await?;
157 self.publish_ci_event_for(&updated, actor, EVENT_HEAD_UPDATED)
158 .await;
157159 }
158160 Ok((
159161 updated,
Modified lib/pulls/src/services/checks.rs View lib/pulls/src/services/checks.rs
@@ -1,8 +1,8 @@
1use ci::triggers::RunDto as PipelineRunDto;
12use git::context::facade::GitContext;
23use git::service::commits::CommitStatusDto;
3use git::service::pipelines::PipelineRunDto;
4use models::ci::workflow_run;
45use models::pull_request::pull_requests;
5use models::repo::pipeline_run;
66use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
77use serde::Serialize;
88use utoipa::ToSchema;
@@ -39,10 +39,10 @@
3939 .git
4040 .list_commit_statuses(space_name.clone(), repo_name.clone(), pull.head_sha.clone())
4141 .await?;
42 let rows = pipeline_run::Entity::find()
43 .filter(pipeline_run::Column::RepoId.eq(pull.repo_id))
44 .filter(pipeline_run::Column::CommitSha.eq(pull.head_sha.clone()))
45 .order_by_desc(pipeline_run::Column::CreatedAt)
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)
4646 .all(self.db_read())
4747 .await?;
4848 Ok(PullChecksDto {
Modified lib/pulls/src/services/events.rs View lib/pulls/src/services/events.rs
@@ -91,9 +91,83 @@
9191 content,
9292 )
9393 .await?;
94 self.publish_ci_event(pull, actor, event_type).await;
9495 Ok(())
9596 }
9697
98 /// Publishes the CI mirror for callers that already wrote their own row
99 /// (for example inside a transaction).
100 pub(crate) async fn publish_ci_event_for(
101 &self,
102 pull: &pull_requests::Model,
103 actor: Option<Uuid>,
104 event_type: &str,
105 ) {
106 self.publish_ci_event(pull, actor, event_type).await;
107 }
108
109 /// Mirrors CI-relevant pull request activity onto the `gitdata:events`
110 /// queue so the scheduler can evaluate `pull_request` workflow triggers.
111 async fn publish_ci_event(
112 &self,
113 pull: &pull_requests::Model,
114 actor: Option<Uuid>,
115 event_type: &str,
116 ) {
117 let action = match event_type {
118 EVENT_CREATED => ci_protocol::events::PullRequestAction::Opened,
119 EVENT_HEAD_UPDATED => ci_protocol::events::PullRequestAction::Synchronize,
120 EVENT_REOPENED => ci_protocol::events::PullRequestAction::Reopened,
121 EVENT_MERGED => ci_protocol::events::PullRequestAction::Merged,
122 EVENT_CLOSED => ci_protocol::events::PullRequestAction::Closed,
123 _ => return,
124 };
125 if !self.config().ci.enabled {
126 return;
127 }
128 let Some(space_id) = self.pull_space_id(pull.repo_id).await else {
129 return;
130 };
131 let event =
132 ci_protocol::events::CiEvent::PullRequest(ci_protocol::events::PullRequestEvent {
133 id: Uuid::now_v7(),
134 repo_id: pull.repo_id,
135 space_id,
136 actor_id: actor.or(Some(pull.author_id)),
137 number: pull.autoincrement_id as i64,
138 action,
139 head_sha: pull.head_sha.clone(),
140 head_ref: pull.head_branch.clone(),
141 base_sha: pull.base_sha.clone(),
142 base_ref: pull.base_branch.clone(),
143 });
144 let payload = match serde_json::to_vec(&event) {
145 Ok(payload) => payload,
146 Err(error) => {
147 tracing::warn!(%error, "ci pull request event serialize failed");
148 return;
149 }
150 };
151 let queue = git::sync::queue::SyncQueue::new(self.state.redis.clone());
152 if let Err(error) = queue
153 .publish_raw(&self.config().ci.event_queue_topic, None, &payload)
154 .await
155 {
156 tracing::warn!(%error, "ci pull request event publish failed");
157 }
158 }
159
160 async fn pull_space_id(&self, repo_id: Uuid) -> Option<Uuid> {
161 let repo = models::repo::repos::Entity::find_by_id(repo_id)
162 .one(self.db_read())
163 .await
164 .ok()??;
165 git::share::access::space_for_dir(self.db_read(), repo.dir_id)
166 .await
167 .ok()?
168 .map(|space| space.id)
169 }
170
97171 pub async fn list_events(
98172 &self,
99173 pull_id: Uuid,
Modified lib/pulls/src/services/pulls.rs View lib/pulls/src/services/pulls.rs
@@ -410,6 +410,8 @@
410410 txn.commit().await?;
411411 self.recount_open(ctx.model.id).await?;
412412 self.queue_search_pull(pull.id, pull.repo_id).await?;
413 self.publish_ci_event_for(&pull, Some(caller), EVENT_CREATED)
414 .await;
413415 Ok(pull)
414416 }
415417
Modified lib/pulls/src/services/relations.rs View lib/pulls/src/services/relations.rs
@@ -116,11 +116,13 @@
116116 Ok(links
117117 .iter()
118118 .filter_map(|link| {
119 by_id.get(&link.pull_request_id).map(|row| IssuePullLinkDto {
120 relation_id: link.id,
121 linked_at: link.created_at,
122 pull: PullService::dto(row, &users),
123 })
119 by_id
120 .get(&link.pull_request_id)
121 .map(|row| IssuePullLinkDto {
122 relation_id: link.id,
123 linked_at: link.created_at,
124 pull: PullService::dto(row, &users),
125 })
124126 })
125127 .collect())
126128 }
Modified lib/pulls/Cargo.toml View lib/pulls/Cargo.toml
@@ -21,3 +21,8 @@
2121tracing = { workspace = true }
2222globset = { workspace = true }
2323search = { workspace = true }
24ci-protocol = { workspace = true }
25ci = { workspace = true }
26
27
28
Modified lib/space/src/handlers/get_space_dir_tree.rs View lib/space/src/handlers/get_space_dir_tree.rs
@@ -1,6 +1,6 @@
11use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
23use serde::Deserialize;
3use export::rest::{AppResponseData, NoData};
44use session::Session;
55
66use crate::errors::ApiError;
Modified lib/space/src/handlers/mod.rs View lib/space/src/handlers/mod.rs
@@ -36,6 +36,7 @@
3636pub mod remove_member;
3737pub mod repair_dir_parents;
3838pub mod revoke_invite;
39pub mod self_member;
3940pub mod set_dir_group_overwrite;
4041pub mod set_dir_user_overwrite;
4142pub mod unassign_group;
@@ -47,4 +48,3 @@
4748pub mod update_member;
4849pub mod update_space;
4950pub mod upload_space_image;
50pub mod self_member;
Modified lib/space/src/services/bans.rs View lib/space/src/services/bans.rs
@@ -130,9 +130,7 @@
130130 .filter(spaces::Column::Id.eq(space.id))
131131 .col_expr(
132132 spaces::Column::NumsMembers,
133 sea_orm::sea_query::Expr::cust(
134 "GREATEST(nums_members - 1, 0)",
135 ),
133 sea_orm::sea_query::Expr::cust("GREATEST(nums_members - 1, 0)"),
136134 )
137135 .exec(txn)
138136 .await?;
@@ -341,6 +339,11 @@
341339 })
342340 .collect();
343341
344 Ok(BanPage { items, total, limit, offset })
342 Ok(BanPage {
343 items,
344 total,
345 limit,
346 offset,
347 })
345348 }
346349}
Modified lib/space/src/services/dir.rs View lib/space/src/services/dir.rs
@@ -18,7 +18,9 @@
1818use crate::errors::SpaceError;
1919use crate::services::service::SpaceService;
2020use crate::services::spaces::UserDto;
21use crate::share::authz::{MANAGE_CHANNELS, MANAGE_OVERWRITES, effective_permissions, require_perm};
21use crate::share::authz::{
22 MANAGE_CHANNELS, MANAGE_OVERWRITES, effective_permissions, require_perm,
23};
2224use crate::share::lookup::lookup_space_by_name;
2325
2426pub const MAX_DIR_NODE_NAME: usize = 255;
Modified lib/space/src/services/groups.rs View lib/space/src/services/groups.rs
@@ -444,7 +444,12 @@
444444 })
445445 .collect();
446446
447 Ok(GroupPage { items, total, limit, offset })
447 Ok(GroupPage {
448 items,
449 total,
450 limit,
451 offset,
452 })
448453 }
449454
450455 pub async fn assign_group(
Modified lib/space/src/services/invites.rs View lib/space/src/services/invites.rs
@@ -267,12 +267,8 @@
267267 }
268268 .insert(self.db().write())
269269 .await?;
270 export::notify::publish_notification(
271 &self.state.redis,
272 invitee,
273 row.to_event_value(),
274 )
275 .await;
270 export::notify::publish_notification(&self.state.redis, invitee, row.to_event_value())
271 .await;
276272 }
277273
278274 let mut recipient_email: Option<String> = email_norm;
@@ -642,6 +638,11 @@
642638 })
643639 .collect();
644640
645 Ok(InvitePage { items, total, limit, offset })
641 Ok(InvitePage {
642 items,
643 total,
644 limit,
645 offset,
646 })
646647 }
647648}
Modified lib/space/src/services/members.rs View lib/space/src/services/members.rs
@@ -541,6 +541,11 @@
541541 })
542542 .collect();
543543
544 Ok(MemberPage { items, total, limit, offset })
544 Ok(MemberPage {
545 items,
546 total,
547 limit,
548 offset,
549 })
545550 }
546551}
Modified lib/space/src/services/spaces.rs View lib/space/src/services/spaces.rs
@@ -47,7 +47,11 @@
4747 }
4848}
4949
50pub fn resolve_space_image_url(space_lower: &str, kind: &str, raw: Option<String>) -> Option<String> {
50pub fn resolve_space_image_url(
51 space_lower: &str,
52 kind: &str,
53 raw: Option<String>,
54) -> Option<String> {
5155 raw.map(|u| {
5256 if u.starts_with("storage://") {
5357 format!("/api/v1/spaces/{space_lower}/images/{kind}")
@@ -353,11 +357,7 @@
353357 name: space.name,
354358 description: space.description,
355359 owner,
356 avatar_url: resolve_space_image_url(
357 &space.lower_name,
358 "avatar",
359 space.avatar_url,
360 ),
360 avatar_url: resolve_space_image_url(&space.lower_name, "avatar", space.avatar_url),
361361 is_private: space.is_private,
362362 is_active: space.is_active,
363363 allow_join: space.allow_join,
@@ -423,7 +423,6 @@
423423 }
424424 am.description = Set(desc);
425425 }
426
427426
428427 if let Some(is_private) = cmd.is_private {
429428 am.is_private = Set(is_private);
@@ -467,11 +466,7 @@
467466 name: updated.name,
468467 description: updated.description,
469468 owner,
470 avatar_url: resolve_space_image_url(
471 &updated.lower_name,
472 "avatar",
473 updated.avatar_url,
474 ),
469 avatar_url: resolve_space_image_url(&updated.lower_name, "avatar", updated.avatar_url),
475470 is_private: updated.is_private,
476471 is_active: updated.is_active,
477472 allow_join: updated.allow_join,
Modified lib/space/src/share/dir_visibility.rs View lib/space/src/share/dir_visibility.rs
@@ -34,10 +34,15 @@
3434 .iter()
3535 .filter(|g| my_groups.contains(&g.group_id))
3636 .collect();
37 if matching.iter().any(|g| (g.deny_mask & PERM_DIR_VISIBLE) != 0) {
37 if matching
38 .iter()
39 .any(|g| (g.deny_mask & PERM_DIR_VISIBLE) != 0)
40 {
3841 return false;
3942 }
40 matching.iter().any(|g| (g.allow_mask & PERM_DIR_VISIBLE) != 0)
43 matching
44 .iter()
45 .any(|g| (g.allow_mask & PERM_DIR_VISIBLE) != 0)
4146}
4247
4348pub async fn is_dir_visible(
Modified lib/user/src/services/avatar.rs View lib/user/src/services/avatar.rs
@@ -99,13 +99,9 @@
9999 .client()
100100 .presign_get(&key, AVATAR_URL_TTL_SECS as u32)
101101 .map_err(UserError::from)?;
102 if let Err(error) = export::redis::set_string(
103 &self.state.redis,
104 &cache_key,
105 &url,
106 AVATAR_URL_TTL_SECS,
107 )
108 .await
102 if let Err(error) =
103 export::redis::set_string(&self.state.redis, &cache_key, &url, AVATAR_URL_TTL_SECS)
104 .await
109105 {
110106 tracing::warn!(%error, "failed to cache avatar presigned url");
111107 }
Modified lib/webapi/src/lib.rs View lib/webapi/src/lib.rs
@@ -54,6 +54,7 @@
5454 let space = space::services::service::SpaceService::new(state.clone());
5555 let user = user::services::service::UserService::new(state.clone());
5656 let git = git::service::GitService::new(state.clone());
57 let ci = ci::CiService::new(state.clone());
5758 let pulls = pulls::services::service::PullService::new(state.clone());
5859 let search =
5960 ::search::SearchService::from_parts(state.database.clone(), state.config.search.clone())
@@ -70,7 +71,8 @@
7071 .merge(git::routes::router(git.clone()).with_state(git.clone()))
7172 .merge(pulls::routes::router().with_state(pulls))
7273 .merge(crate::search::router().with_state(search.clone()))
73 .merge(::import::routes::router(import.clone()).with_state(import.clone()));
74 .merge(::import::routes::router(import.clone()).with_state(import.clone()))
75 .merge(ci::routes::router().with_state(ci.clone()));
7476 let internal: Router = Router::new()
7577 .merge(git::routes::internal_router(git.clone()).with_state(git))
7678 .merge(crate::search::internal_router().with_state(search))
Modified lib/webapi/src/openapi.rs View lib/webapi/src/openapi.rs
@@ -113,6 +113,7 @@
113113 m!("pulls", pulls::openapi::PullsApiDoc::openapi());
114114 m!("search", crate::search::SearchApiDoc::openapi());
115115 m!("import", import::openapi::ImportApiDoc::openapi());
116 m!("ci", ci::openapi::PipelinesApiDoc::openapi());
116117 doc.servers = Some(vec![utoipa::openapi::Server::new("/api/v1")]);
117118 doc.info.description = Some(CONVENTIONS.to_string());
118119 doc.info.license = None;
Modified lib/webapi/src/spa.rs View lib/webapi/src/spa.rs
@@ -76,10 +76,7 @@
7676 {
7777 return (
7878 StatusCode::NOT_MODIFIED,
79 [
80 (header::CACHE_CONTROL, cache_control),
81 (header::ETAG, etag),
82 ],
79 [(header::CACHE_CONTROL, cache_control), (header::ETAG, etag)],
8380 )
8481 .into_response();
8582 }
Modified lib/webapi/Cargo.toml View lib/webapi/Cargo.toml
@@ -12,6 +12,7 @@
1212space = { workspace = true }
1313user = { workspace = true }
1414git = { workspace = true }
15ci = { workspace = true }
1516import = { workspace = true }
1617search = { workspace = true }
1718export = { workspace = true }
@@ -28,3 +29,4 @@
2829web = { workspace = true }
2930serde = { workspace = true, features = ["derive"] }
3031uuid = { workspace = true }
32
Added web/scripts/flatten-openapi.mjs View web/scripts/flatten-openapi.mjs
@@ -0,0 +1,40 @@
1#!/usr/bin/env bun
2// Flattens `allOf` composition in the OpenAPI document so the Alova
3// generator (which cannot resolve composed schemas) can process it.
4// Usage: bun scripts/flatten-openapi.mjs <input> <output>
5import { readFileSync, writeFileSync } from 'node:fs'
6
7function mergeInto(target, source) {
8 if (!source || typeof source !== 'object') return
9 for (const [key, value] of Object.entries(source)) {
10 if (key === 'allOf') continue
11 if (key === 'required' && Array.isArray(value)) {
12 target.required = [...new Set([...(target.required ?? []), ...value])]
13 continue
14 }
15 if (key === 'properties' && value && typeof value === 'object') {
16 target.properties = { ...(target.properties ?? {}), ...value }
17 continue
18 }
19 if (target[key] === undefined) target[key] = value
20 }
21}
22
23function flatten(node) {
24 if (Array.isArray(node)) return node.map(flatten)
25 if (!node || typeof node !== 'object') return node
26 const out = {}
27 for (const [key, value] of Object.entries(node)) {
28 if (key === 'allOf' && Array.isArray(value)) {
29 for (const part of value) mergeInto(out, flatten(part))
30 continue
31 }
32 out[key] = flatten(value)
33 }
34 return out
35}
36
37const [input, output] = process.argv.slice(2)
38const doc = JSON.parse(readFileSync(input, 'utf8'))
39writeFileSync(output, JSON.stringify(flatten(doc), null, 2))
40console.log(`flattened ${input} -> ${output}`)
Modified web/scripts/lint-nav.mjs View web/scripts/lint-nav.mjs
@@ -52,7 +52,11 @@
5252 readFileSync(file, 'utf8')
5353 .split('\n')
5454 .forEach((line, i) => {
55 // Fragment-only anchors (`href="#L12"`) never trigger a document reload,
56 // so they are allowed outside the auth flow as in-page permalinks.
57 const fragmentOnly = line.includes('href={`#') || /href=["'{]#/.test(line)
5558 for (const [label, re] of PATTERNS) {
59 if (fragmentOnly && label !== 'full-page navigation' && label !== 'browser-level refresh') continue
5660 if (re.test(line)) violations.push(`${rel}:${i + 1}: ${label} — ${line.trim()}`)
5761 }
5862 })
Added web/src/entities/pipeline/model/pipeline.ts View web/src/entities/pipeline/model/pipeline.ts
@@ -0,0 +1,109 @@
1/**
2 * Pipeline domain: run list/detail, logs, artifacts, workflows and repo secrets.
3 */
4import Apis from '@/shared/api'
5import { unwrapApi } from '@/shared/lib'
6import type {
7 AppResponseData_LogPageDto,
8 AppResponseData_RunDetailDto,
9 AppResponseData_RunPageDto,
10 AppResponseData_WorkflowSummaryList,
11 TriggerRunCmd,
12} from '@/shared/api'
13
14export type RunDetail = NonNullable<AppResponseData_RunDetailDto['data']>
15export type RunPage = NonNullable<AppResponseData_RunPageDto['data']>
16export type RunSummary = RunDetail['run']
17export type RunJob = RunDetail['jobs'][number]
18export type RunStep = RunJob['steps'][number]
19export type RunArtifact = RunDetail['artifacts'][number]
20export type LogPage = NonNullable<AppResponseData_LogPageDto['data']>
21export type WorkflowList = NonNullable<AppResponseData_WorkflowSummaryList['data']>
22
23export const fetchRuns = (space: string, repo: string, limit = 50, offset = 0) =>
24 unwrapApi<RunPage>(
25 Apis.pipelines.list_runs({
26 pathParams: { space_name: space, repo_name: repo },
27 params: { limit, offset },
28 }),
29 )
30
31export const fetchRun = (space: string, repo: string, runId: string) =>
32 unwrapApi<RunDetail>(
33 Apis.pipelines.get_run({
34 pathParams: { space_name: space, repo_name: repo, run_id: runId },
35 }),
36 )
37
38export const cancelRun = (space: string, repo: string, runId: string) =>
39 unwrapApi<RunDetail>(
40 Apis.pipelines.cancel_run({
41 pathParams: { space_name: space, repo_name: repo, run_id: runId },
42 }),
43 )
44
45export const rerun = (space: string, repo: string, runId: string) =>
46 unwrapApi<RunDetail>(
47 Apis.pipelines.rerun({
48 pathParams: { space_name: space, repo_name: repo, run_id: runId },
49 }),
50 )
51
52export const triggerRun = (space: string, repo: string, cmd: TriggerRunCmd) =>
53 unwrapApi<RunDetail>(
54 Apis.pipelines.trigger({
55 pathParams: { space_name: space, repo_name: repo },
56 data: cmd,
57 }),
58 )
59
60export const fetchWorkflows = (space: string, repo: string, revision?: string) =>
61 unwrapApi<WorkflowList>(
62 Apis.pipelines.list_workflows({
63 pathParams: { space_name: space, repo_name: repo },
64 params: revision ? { revision } : {},
65 }),
66 )
67
68export const fetchLogs = (
69 space: string,
70 repo: string,
71 runId: string,
72 jobId: string,
73 afterSeq = 0,
74) =>
75 unwrapApi<LogPage>(
76 Apis.pipelines.read_logs({
77 pathParams: {
78 space_name: space,
79 repo_name: repo,
80 run_id: runId,
81 job_id: jobId,
82 },
83 params: { after_seq: afterSeq, limit: 100 },
84 }),
85 )
86
87export const fetchRepoSecrets = (space: string, repo: string) =>
88 unwrapApi<{ secrets: Array<{ id: string; name: string; updated_at: string }> }>(
89 Apis.pipelines.list_repo_secrets({
90 pathParams: { space_name: space, repo_name: repo },
91 }),
92 )
93
94export const upsertRepoSecret = (space: string, repo: string, name: string, value: string) =>
95 unwrapApi<unknown>(
96 Apis.pipelines.upsert_repo_secret({
97 pathParams: { space_name: space, repo_name: repo, name },
98 data: { value },
99 }),
100 )
101
102export const deleteRepoSecret = (space: string, repo: string, name: string) =>
103 unwrapApi<unknown>(
104 Apis.pipelines.delete_repo_secret({
105 pathParams: { space_name: space, repo_name: repo, name },
106 }),
107 )
108
109
Added web/src/entities/pipeline/index.ts View web/src/entities/pipeline/index.ts
@@ -0,0 +1 @@
1export * from './model/pipeline'
Added web/src/features/repo-pipelines/ui/pipelines-view.tsx View web/src/features/repo-pipelines/ui/pipelines-view.tsx
@@ -0,0 +1,502 @@
1import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2import { ChevronLeft, RefreshCw, RotateCw, Play, XCircle, Download } from 'lucide-react'
3import { Alert, Button, PanelNote, modalInputClass, modalLabelClass } from '@/shared/ui'
4import { useAsync } from '@/shared/lib'
5import {
6 cancelRun,
7 fetchLogs,
8 fetchRun,
9 fetchRuns,
10 fetchWorkflows,
11 rerun,
12 triggerRun,
13 type RunDetail,
14 type RunJob,
15 type RunSummary,
16} from '@/entities/pipeline'
17
18const STATE_LABEL: Record<string, string> = {
19 queued: '排队中',
20 pending: '等待依赖',
21 claimed: '已领取',
22 running: '运行中',
23 success: '成功',
24 failure: '失败',
25 timed_out: '超时',
26 cancelled: '已取消',
27 cancel_requested: '取消中',
28 skipped: '已跳过',
29 ready: '就绪',
30 uploading: '上传中',
31 failed: '失败',
32}
33
34const ACTIVE_STATES = new Set(['queued', 'running', 'cancel_requested', 'pending', 'claimed'])
35
36function stateTone(state: string): string {
37 switch (state) {
38 case 'success':
39 return 'bg-emerald-500/12 text-emerald-500'
40 case 'failure':
41 case 'timed_out':
42 case 'failed':
43 return 'bg-red-500/12 text-red-400'
44 case 'cancelled':
45 case 'skipped':
46 return 'bg-shell-field text-shell-ink-muted'
47 case 'running':
48 case 'claimed':
49 return 'bg-sky-500/12 text-sky-400'
50 default:
51 return 'bg-amber-500/12 text-amber-500'
52 }
53}
54
55function StateBadge({ state }: { state: string }) {
56 return (
57 <span className={`rounded-md px-2 py-0.5 text-[12px] font-medium ${stateTone(state)}`}>
58 {STATE_LABEL[state] ?? state}
59 </span>
60 )
61}
62
63function shortSha(sha: string) {
64 return sha.slice(0, 8)
65}
66
67export function PipelinesView({
68 space,
69 repo,
70 defaultBranch,
71 runId,
72 onSelectRun,
73}: {
74 space: string
75 repo: string
76 defaultBranch?: string
77 runId: string
78 onSelectRun: (runId: string) => void
79}) {
80 const [triggering, setTriggering] = useState(false)
81 const [tick, setTick] = useState(0)
82 const runs = useAsync(
83 useCallback(() => fetchRuns(space, repo, 50, 0), [space, repo]),
84 [space, repo, tick],
85 )
86
87 const refresh = () => setTick(value => value + 1)
88
89 if (runId) {
90 return (
91 <RunDetailView
92 space={space}
93 repo={repo}
94 runId={runId}
95 onBack={() => onSelectRun('')}
96 />
97 )
98 }
99
100 return (
101 <div className="space-y-4 pt-2">
102 <div className="flex items-center justify-between gap-3">
103 <div>
104 <h3 className="text-[16px] font-semibold">流水线</h3>
105 <p className="mt-1 text-[13px] text-shell-ink-muted">
106 由 <code className="text-shell-ink">.workflows/</code> 中的定义驱动,runner 通过调度中心领取作业。
107 </p>
108 </div>
109 <div className="flex items-center gap-2">
110 <Button size="default" variant="secondary" onClick={refresh}>
111 <RefreshCw className="mr-1 h-3.5 w-3.5" /> 刷新
112 </Button>
113 <Button size="default" onClick={() => setTriggering(true)}>
114 <Play className="mr-1 h-3.5 w-3.5" /> 手动触发
115 </Button>
116 </div>
117 </div>
118
119 {runs.error ? (
120 <Alert tone="error">
121 {runs.error instanceof Error ? runs.error.message : '加载失败'}
122 </Alert>
123 ) : null}
124
125 {triggering ? (
126 <TriggerDialog
127 space={space}
128 repo={repo}
129 defaultRef={defaultBranch ?? 'main'}
130 onClose={() => setTriggering(false)}
131 onTriggered={(created) => {
132 setTriggering(false)
133 refresh()
134 if (created?.id) onSelectRun(created.id)
135 }}
136 />
137 ) : null}
138
139 {runs.loading && !runs.data ? (
140 <PanelNote text="正在加载运行记录…" />
141 ) : null}
142
143 {runs.data && runs.data.runs.length === 0 ? (
144 <PanelNote text="还没有运行记录。推送包含 .workflows/*.yml 的提交,或在此手动触发。" />
145 ) : null}
146
147 {runs.data && runs.data.runs.length > 0 ? (
148 <ul className="space-y-2">
149 {runs.data.runs.map((run) => (
150 <li key={run.id}>
151 <button
152 type="button"
153 onClick={() => onSelectRun(run.id)}
154 className="w-full rounded-xl border border-shell-hairline bg-shell-field px-4 py-3 text-left transition-colors hover:border-shell-tile"
155 >
156 <div className="flex flex-wrap items-center gap-3">
157 <StateBadge state={run.state} />
158 <span className="text-[13.5px] font-medium">{run.workflow_name}</span>
159 <span className="text-[12.5px] text-shell-ink-muted">
160 {run.event} · {run.branch ?? run.tag ?? run.git_ref} · {shortSha(run.commit_sha)}
161 </span>
162 <span className="ml-auto text-[12px] text-shell-ink-muted">
163 {new Date(run.created_at).toLocaleString()}
164 </span>
165 </div>
166 </button>
167 </li>
168 ))}
169 </ul>
170 ) : null}
171 </div>
172 )
173}
174
175function TriggerDialog({
176 space,
177 repo,
178 defaultRef,
179 onClose,
180 onTriggered,
181}: {
182 space: string
183 repo: string
184 defaultRef: string
185 onClose: () => void
186 onTriggered: (run: RunSummary | null) => void
187}) {
188 const [gitRef, setGitRef] = useState(defaultRef)
189 const [workflowInput, setWorkflowInput] = useState('')
190 const [busy, setBusy] = useState(false)
191 const [error, setError] = useState<string | null>(null)
192 const workflows = useAsync(
193 useCallback(() => fetchWorkflows(space, repo, gitRef.trim() || undefined), [space, repo, gitRef]),
194 [space, repo, gitRef],
195 )
196
197 const dispatchable = useMemo(
198 () => (workflows.data?.workflows ?? []).filter((w) => w.valid && w.triggers.workflow_dispatch),
199 [workflows.data],
200 )
201
202 const workflow = workflowInput || dispatchable[0]?.file || ''
203
204 const submit = async () => {
205 setBusy(true)
206 setError(null)
207 try {
208 const detail = await triggerRun(space, repo, { workflow, git_ref: gitRef.trim() })
209 onTriggered(detail.run)
210 } catch (err) {
211 setError(err instanceof Error ? err.message : '触发失败')
212 } finally {
213 setBusy(false)
214 }
215 }
216
217 return (
218 <div className="rounded-xl border border-shell-hairline bg-shell-surface p-4">
219 <h4 className="mb-3 text-[14px] font-semibold">手动触发 workflow_dispatch</h4>
220 <label className="mb-3 block">
221 <span className={modalLabelClass}>分支或标签</span>
222 <input
223 className={modalInputClass}
224 value={gitRef}
225 onChange={(event) => setGitRef(event.target.value)}
226 placeholder="main"
227 />
228 </label>
229 <label className="mb-3 block">
230 <span className={modalLabelClass}>工作流</span>
231 <select
232 className={modalInputClass}
233 value={workflow}
234 onChange={(event) => setWorkflowInput(event.target.value)}
235 >
236 {dispatchable.map((item) => (
237 <option key={item.file} value={item.file}>
238 {item.name} ({item.file})
239 </option>
240 ))}
241 </select>
242 </label>
243 {workflows.error ? (
244 <Alert tone="error">
245 {workflows.error instanceof Error ? workflows.error.message : '工作流加载失败'}
246 </Alert>
247 ) : null}
248 {!workflows.loading && dispatchable.length === 0 ? (
249 <PanelNote text="该修订下没有可手动触发的工作流(需在 on.workflow_dispatch 中声明)。" />
250 ) : null}
251 {error ? <Alert tone="error">{error}</Alert> : null}
252 <div className="mt-3 flex justify-end gap-2">
253 <Button size="default" variant="secondary" onClick={onClose}>
254 取消
255 </Button>
256 <Button size="default" disabled={busy || !workflow || !gitRef.trim()} onClick={submit}>
257 {busy ? '触发中…' : '触发'}
258 </Button>
259 </div>
260 </div>
261 )
262}
263
264function RunDetailView({
265 space,
266 repo,
267 runId,
268 onBack,
269}: {
270 space: string
271 repo: string
272 runId: string
273 onBack: () => void
274}) {
275 const [detail, setDetail] = useState<RunDetail | null>(null)
276 const [error, setError] = useState<string | null>(null)
277 const [selectedJob, setSelectedJob] = useState<string>('')
278
279 const load = useCallback(async () => {
280 try {
281 const next = await fetchRun(space, repo, runId)
282 setDetail(next)
283 setError(null)
284 setSelectedJob((current) => current || next.jobs[0]?.id || '')
285 } catch (err) {
286 setError(err instanceof Error ? err.message : '加载失败')
287 }
288 }, [space, repo, runId])
289
290 useEffect(() => {
291 void load()
292 }, [load])
293
294 useEffect(() => {
295 if (!detail) return
296 if (!ACTIVE_STATES.has(detail.run.state)) return
297 const timer = window.setInterval(() => {
298 void load()
299 }, 3000)
300 return () => window.clearInterval(timer)
301 }, [detail, load])
302
303 const action = async (fn: () => Promise<RunDetail>) => {
304 try {
305 setDetail(await fn())
306 setError(null)
307 } catch (err) {
308 setError(err instanceof Error ? err.message : '操作失败')
309 }
310 }
311
312 const active = detail ? ACTIVE_STATES.has(detail.run.state) : false
313 const finished = detail
314 ? ['success', 'failure', 'cancelled'].includes(detail.run.state)
315 : false
316
317 return (
318 <div className="space-y-4 pt-2">
319 <div className="flex flex-wrap items-center gap-3">
320 <Button size="default" variant="secondary" onClick={onBack}>
321 <ChevronLeft className="mr-1 h-3.5 w-3.5" /> 返回
322 </Button>
323 {detail ? (
324 <>
325 <StateBadge state={detail.run.state} />
326 <span className="text-[14px] font-semibold">{detail.run.workflow_name}</span>
327 <span className="text-[12.5px] text-shell-ink-muted">
328 {detail.run.event} · {detail.run.branch ?? detail.run.tag ?? detail.run.git_ref} ·{' '}
329 {shortSha(detail.run.commit_sha)}
330 </span>
331 </>
332 ) : null}
333 <div className="ml-auto flex items-center gap-2">
334 {active ? (
335 <Button size="default" variant="secondary" onClick={() => void action(() => cancelRun(space, repo, runId))}>
336 <XCircle className="mr-1 h-3.5 w-3.5" /> 取消
337 </Button>
338 ) : null}
339 {finished ? (
340 <Button size="default" variant="secondary" onClick={() => void action(() => rerun(space, repo, runId))}>
341 <RotateCw className="mr-1 h-3.5 w-3.5" /> 重跑
342 </Button>
343 ) : null}
344 <Button size="default" variant="secondary" onClick={() => void load()}>
345 <RefreshCw className="mr-1 h-3.5 w-3.5" /> 刷新
346 </Button>
347 </div>
348 </div>
349
350 {error ? <Alert tone="error">{error}</Alert> : null}
351 {!detail ? <PanelNote text="正在加载运行详情…" /> : null}
352
353 {detail ? (
354 <div className="grid gap-4 lg:grid-cols-[260px_minmax(0,1fr)]">
355 <div className="space-y-2">
356 <h4 className="text-[13px] font-semibold text-shell-ink-muted">作业</h4>
357 <ul className="space-y-1.5">
358 {detail.jobs.map((job) => (
359 <li key={job.id}>
360 <button
361 type="button"
362 onClick={() => setSelectedJob(job.id)}
363 className={`w-full rounded-lg border px-3 py-2 text-left text-[13px] transition-colors ${
364 selectedJob === job.id
365 ? 'border-shell-tile bg-shell-field'
366 : 'border-shell-hairline hover:border-shell-tile'
367 }`}
368 >
369 <div className="flex items-center justify-between gap-2">
370 <span className="truncate font-medium">{job.name}</span>
371 <StateBadge state={job.state} />
372 </div>
373 <div className="mt-1 text-[11.5px] text-shell-ink-muted">
374 {job.runs_on.join(', ')}
375 {job.environment ? ` · ${job.environment}` : ''}
376 </div>
377 </button>
378 </li>
379 ))}
380 </ul>
381
382 {detail.artifacts.length > 0 ? (
383 <>
384 <h4 className="pt-2 text-[13px] font-semibold text-shell-ink-muted">产物</h4>
385 <ul className="space-y-1.5">
386 {detail.artifacts.map((artifact) => (
387 <li
388 key={artifact.id}
389 className="flex items-center justify-between gap-2 rounded-lg border border-shell-hairline px-3 py-2 text-[12.5px]"
390 >
391 <span className="truncate">{artifact.name}</span>
392 {artifact.state === 'ready' ? (
393 <button
394 type="button"
395 className="flex items-center gap-1 text-sky-400 hover:underline"
396 onClick={() =>
397 window.open(
398 `/api/v1/repos/${space}/${repo}/pipelines/artifacts/${artifact.id}/download`,
399 '_blank',
400 'noopener,noreferrer',
401 )
402 }
403 >
404 <Download className="h-3.5 w-3.5" /> 下载
405 </button>
406 ) : (
407 <span className="text-shell-ink-muted">{STATE_LABEL[artifact.state] ?? artifact.state}</span>
408 )}
409 </li>
410 ))}
411 </ul>
412 </>
413 ) : null}
414 </div>
415
416 <div className="min-w-0 space-y-3">
417 {detail.jobs
418 .filter((job) => job.id === selectedJob)
419 .map((job) => (
420 <JobPanel key={job.id} space={space} repo={repo} runId={runId} job={job} />
421 ))}
422 </div>
423 </div>
424 ) : null}
425 </div>
426 )
427}
428
429function JobPanel({
430 space,
431 repo,
432 runId,
433 job,
434}: {
435 space: string
436 repo: string
437 runId: string
438 job: RunJob
439}) {
440 const [log, setLog] = useState('')
441 const seqRef = useRef(0)
442 const completeRef = useRef(false)
443
444 useEffect(() => {
445 let cancelled = false
446 const tick = async () => {
447 if (cancelled || completeRef.current) return
448 try {
449 const page = await fetchLogs(space, repo, runId, job.id, seqRef.current)
450 if (cancelled) return
451 if (page.chunks.length > 0) {
452 setLog((current) => current + page.chunks.map((chunk) => chunk.text).join(''))
453 seqRef.current = page.next_seq
454 }
455 completeRef.current = page.complete
456 } catch {
457 // Polling is best-effort; the next tick retries.
458 }
459 }
460 void tick()
461 const timer = window.setInterval(() => void tick(), 2000)
462 return () => {
463 cancelled = true
464 window.clearInterval(timer)
465 }
466 }, [space, repo, runId, job.id])
467
468 return (
469 <div className="space-y-3">
470 <div className="flex flex-wrap items-center gap-2">
471 <h4 className="text-[14px] font-semibold">{job.name}</h4>
472 <StateBadge state={job.state} />
473 {job.attempt > 1 ? (
474 <span className="text-[12px] text-shell-ink-muted">第 {job.attempt} 次尝试</span>
475 ) : null}
476 {job.error ? <span className="text-[12px] text-red-400">{job.error}</span> : null}
477 </div>
478
479 <ol className="space-y-1.5">
480 {job.steps.map((step) => (
481 <li
482 key={step.index}
483 className="flex items-center gap-3 rounded-lg border border-shell-hairline px-3 py-2 text-[12.5px]"
484 >
485 <span className="w-6 text-shell-ink-muted">{step.index + 1}</span>
486 <span className="min-w-0 flex-1 truncate">{step.name}</span>
487 <StateBadge state={step.state} />
488 </li>
489 ))}
490 </ol>
491
492 <div>
493 <h5 className="mb-1.5 text-[13px] font-semibold text-shell-ink-muted">日志</h5>
494 <pre className="max-h-[420px] overflow-auto rounded-xl border border-shell-hairline bg-black/40 px-3 py-3 text-[12px] leading-5 text-shell-ink">
495 {log || '(暂无日志输出)'}
496 </pre>
497 </div>
498 </div>
499 )
500}
501
502
Added web/src/features/repo-pipelines/index.ts View web/src/features/repo-pipelines/index.ts
@@ -0,0 +1 @@
1export { PipelinesView } from './ui/pipelines-view'
Modified web/src/features/repo-settings/model/sections.ts View web/src/features/repo-settings/model/sections.ts
@@ -15,6 +15,7 @@
1515 | 'insights'
1616 | 'locks'
1717 | 'protection'
18 | 'ci'
1819
1920export const REPO_SETTINGS_SECTIONS: ReadonlyArray<{ id: RepoSettingsSection; label: string }> = [
2021 { id: 'general', label: '常规' },
@@ -25,4 +26,6 @@
2526 { id: 'insights', label: '分析与维护' },
2627 { id: 'locks', label: 'LFS 锁' },
2728 { id: 'protection', label: '保护规则' },
29 { id: 'ci', label: 'CI/CD' },
2830]
31
Added web/src/features/repo-settings/ui/sections/ci-settings.tsx View web/src/features/repo-settings/ui/sections/ci-settings.tsx
@@ -0,0 +1,162 @@
1import { useCallback, useState } from 'react'
2import { Trash2 } from 'lucide-react'
3import { Alert, Button, PanelNote } from '@/shared/ui'
4import { useAsync } from '@/shared/lib'
5import {
6 deleteRepoSecret,
7 fetchRepoSecrets,
8 fetchWorkflows,
9 upsertRepoSecret,
10 type WorkflowList,
11} from '@/entities/pipeline'
12import { Field, ListRow, Notice, SectionHead, inputCls } from '../parts'
13
14/**
15 * CI/CD settings: workflow definitions found in `.workflows/` and repo-level
16 * secrets. Secret values are write-only — the API never returns them.
17 */
18export function CiSettings({ space, repo, rev }: { space: string; repo: string; rev: string }) {
19 const [tick, setTick] = useState(0)
20 const [name, setName] = useState('')
21 const [value, setValue] = useState('')
22 const [notice, setNotice] = useState<string | null>(null)
23 const [error, setError] = useState<string | null>(null)
24
25 const workflows = useAsync(
26 useCallback(() => fetchWorkflows(space, repo, rev || undefined), [space, repo, rev]),
27 [space, repo, rev, tick],
28 )
29 const secrets = useAsync(
30 useCallback(() => fetchRepoSecrets(space, repo), [space, repo]),
31 [space, repo, tick],
32 )
33
34 const save = async () => {
35 setNotice(null)
36 setError(null)
37 try {
38 await upsertRepoSecret(space, repo, name.trim(), value)
39 setName('')
40 setValue('')
41 setNotice('secret 已保存')
42 setTick(t => t + 1)
43 } catch (err) {
44 setError(err instanceof Error ? err.message : '保存失败')
45 }
46 }
47
48 const remove = async (target: string) => {
49 setNotice(null)
50 setError(null)
51 try {
52 await deleteRepoSecret(space, repo, target)
53 setNotice('secret 已删除')
54 setTick(t => t + 1)
55 } catch (err) {
56 setError(err instanceof Error ? err.message : '删除失败')
57 }
58 }
59
60 const list: WorkflowList | undefined = workflows.data
61
62 return (
63 <div>
64 <SectionHead
65 title="CI/CD"
66 sub="工作流定义来自默认分支(或当前修订)的 .workflows/ 目录;runner 通过调度中心领取作业。"
67 />
68 <Notice notice={notice} error={error} />
69
70 <h4 className="mb-3 text-[14px] font-semibold">工作流</h4>
71 {workflows.error ? (
72 <Alert tone="error">
73 {workflows.error instanceof Error ? workflows.error.message : '工作流加载失败'}
74 </Alert>
75 ) : null}
76 {workflows.loading && !list ? <PanelNote text="正在读取 .workflows/…" /> : null}
77 {list && list.workflows.length === 0 ? (
78 <PanelNote text="该修订下没有 .workflows/*.yml 定义。" />
79 ) : null}
80 {list && list.workflows.length > 0 ? (
81 <ul className="mb-6 space-y-2">
82 {list.workflows.map(item => (
83 <ListRow key={item.file}>
84 <span className="text-[13.5px] font-medium">{item.name}</span>
85 <code className="text-[12px] text-shell-ink-muted">{item.file}</code>
86 <span
87 className={`ml-auto rounded-md px-2 py-0.5 text-[12px] ${
88 item.valid ? 'bg-emerald-500/12 text-emerald-500' : 'bg-red-500/12 text-red-400'
89 }`}
90 >
91 {item.valid ? '有效' : '解析失败'}
92 </span>
93 {!item.valid && item.errors.length > 0 ? (
94 <span className="w-full text-[12px] text-red-400">{item.errors.join('; ')}</span>
95 ) : null}
96 <span className="w-full text-[12px] text-shell-ink-muted">
97 {item.triggers.push_branches.length > 0
98 ? `push: ${item.triggers.push_branches.join(', ')} `
99 : ''}
100 {item.triggers.push_tags.length > 0 ? `tags: ${item.triggers.push_tags.join(', ')} ` : ''}
101 {item.triggers.pull_request_branches.length > 0
102 ? `PR: ${item.triggers.pull_request_branches.join(', ')} `
103 : ''}
104 {item.triggers.workflow_dispatch ? '手动触发可用' : ''}
105 </span>
106 </ListRow>
107 ))}
108 </ul>
109 ) : null}
110
111 <h4 className="mb-3 text-[14px] font-semibold">仓库 secrets</h4>
112 {secrets.error ? (
113 <Alert tone="error">
114 {secrets.error instanceof Error ? secrets.error.message : 'secret 加载失败'}
115 </Alert>
116 ) : null}
117 {secrets.data ? (
118 <ul className="mb-5 space-y-2">
119 {secrets.data.secrets.map(item => (
120 <ListRow key={item.id}>
121 <span className="text-[13.5px] font-medium">{item.name}</span>
122 <span className="text-[12px] text-shell-ink-muted">
123 更新于 {new Date(item.updated_at).toLocaleString()}
124 </span>
125 <Button
126 variant="secondary"
127 className="ml-auto"
128 onClick={() => void remove(item.name)}
129 >
130 <Trash2 className="h-3.5 w-3.5" />
131 </Button>
132 </ListRow>
133 ))}
134 {secrets.data.secrets.length === 0 ? (
135 <li className="text-[12.5px] text-shell-ink-muted">尚未配置 secret。</li>
136 ) : null}
137 </ul>
138 ) : null}
139
140 <Field label="新增 / 覆盖 secret" hint="值只会写库(AES-GCM 加密),接口不会回显。">
141 <input
142 className={inputCls}
143 value={name}
144 onChange={event => setName(event.target.value)}
145 placeholder="名称(字母、数字、_、-)"
146 />
147 </Field>
148 <Field label="值">
149 <input
150 className={inputCls}
151 type="password"
152 value={value}
153 onChange={event => setValue(event.target.value)}
154 placeholder="secret 值"
155 />
156 </Field>
157 <Button disabled={!name.trim() || !value} onClick={() => void save()}>
158 保存 secret
159 </Button>
160 </div>
161 )
162}
Modified web/src/features/repo-settings/ui/settings-tab.tsx View web/src/features/repo-settings/ui/settings-tab.tsx
@@ -1,6 +1,7 @@
11import type { RepoDetail } from '@/entities/repo'
22import { REPO_SETTINGS_SECTIONS, type RepoSettingsSection } from '../model/sections'
33import { ArchivesSettings } from './sections/archives-settings'
4import { CiSettings } from './sections/ci-settings'
45import { GeneralSettings } from './sections/general-settings'
56import { ImportSettings } from './sections/import-settings'
67import { InsightsSettings } from './sections/insights-settings'
@@ -58,6 +59,7 @@
5859 {section === 'general' ? <GeneralSettings space={space} repo={repo} repoDto={repoDto} onChanged={onSaved} /> : null}
5960 {section === 'topics' ? <TopicsSettings space={space} repo={repo} /> : null}
6061 {section === 'protection' ? <ProtectionSettings space={space} repo={repo} /> : null}
62 {section === 'ci' ? <CiSettings space={space} repo={repo} rev={rev} /> : null}
6163 {section === 'remotes' ? <RemotesSettings space={space} repo={repo} /> : null}
6264 {section === 'import' ? <ImportSettings space={space} repo={repo} /> : null}
6365 {section === 'archives' ? (
@@ -77,3 +79,4 @@
7779 </div>
7880 )
7981}
82
Modified web/src/pages/workspace/model/repo-location.ts View web/src/pages/workspace/model/repo-location.ts
@@ -53,6 +53,8 @@
5353 pull: string
5454 /** pull: sub-tab inside the PR detail. */
5555 ptab: PullSubTab
56 /** pipelines: opened run id ('' = run list). */
57 run: string
5658 /** settings: active second-level section. */
5759 section: RepoSettingsSection
5860}
@@ -69,16 +71,39 @@
6971 diff: '',
7072 pull: '',
7173 ptab: 'conversation',
74 run: '',
7275 section: 'general',
7376}
7477
75const TAB_VALUES: readonly RepoTabId[] = ['code', 'commits', 'pulls', 'pull', 'branches', 'settings']
78const TAB_VALUES: readonly RepoTabId[] = [
79 'code',
80 'commits',
81 'pulls',
82 'pull',
83 'branches',
84 'pipelines',
85 'settings',
86]
7687const SECTION_VALUES = REPO_SETTINGS_SECTIONS.map(section => section.id)
7788const DIFF_VALUES: readonly DiffStyle[] = ['unified', 'split']
7889const PTAB_VALUES: readonly PullSubTab[] = ['conversation', 'files', 'commits', 'checks']
7990
8091/** Query keys owned by the repo page — the only ones this hook may rewrite. */
81const OWNED_KEYS = ['tab', 'rev', 'path', 'file', 'oid', 'line', 'commit', 'fp', 'diff', 'pull', 'ptab', 'section']
92const OWNED_KEYS = [
93 'tab',
94 'rev',
95 'path',
96 'file',
97 'oid',
98 'line',
99 'commit',
100 'fp',
101 'diff',
102 'pull',
103 'ptab',
104 'section',
105 'run',
106]
82107
83108/**
84109 * Fields that mean something *inside* a tab. Leaving a tab drops the others,
@@ -92,6 +117,7 @@
92117 pulls: ['tab', 'rev'],
93118 pull: ['tab', 'rev', 'pull', 'ptab'],
94119 branches: ['tab', 'rev'],
120 pipelines: ['tab', 'rev', 'run'],
95121 settings: ['tab', 'rev', 'section'],
96122}
97123
@@ -114,6 +140,7 @@
114140 diff: pick(params.get('diff'), DIFF_VALUES, '') as DiffStyle | '',
115141 pull: params.get('pull') ?? '',
116142 ptab: pick(params.get('ptab'), PTAB_VALUES, 'conversation'),
143 run: params.get('run') ?? '',
117144 section: pick(params.get('section'), SECTION_VALUES, 'general'),
118145 }
119146}
@@ -152,6 +179,7 @@
152179 if (state.diff) next.set('diff', state.diff)
153180 if (state.pull) next.set('pull', state.pull)
154181 if (state.ptab !== 'conversation') next.set('ptab', state.ptab)
182 if (state.run) next.set('run', state.run)
155183 if (state.section !== 'general') next.set('section', state.section)
156184 return next
157185}
@@ -170,6 +198,7 @@
170198 diff: owns('diff') ? state.diff : '',
171199 pull: owns('pull') ? state.pull : '',
172200 ptab: owns('ptab') ? state.ptab : 'conversation',
201 run: owns('run') ? state.run : '',
173202 section: owns('section') ? state.section : 'general',
174203 }
175204}
@@ -303,3 +332,5 @@
303332 normalizeForRepo,
304333 }
305334}
335
336
Modified web/src/pages/workspace/model/route.ts View web/src/pages/workspace/model/route.ts
@@ -2,7 +2,14 @@
22export type ShellMode = 'chat' | 'board' | 'repo' | 'notify'
33
44/** Repo page tabs, rendered by `RepoLayout`'s default tab strip. */
5export type RepoTabId = 'code' | 'commits' | 'pulls' | 'pull' | 'branches' | 'settings'
5export type RepoTabId =
6 | 'code'
7 | 'commits'
8 | 'pulls'
9 | 'pull'
10 | 'branches'
11 | 'pipelines'
12 | 'settings'
613
714/** Dynamic segments of `/app/:spaceName/{chat|board|repo}/:id?`. */
815export interface ShellParams extends Record<string, string | undefined> {
@@ -20,3 +27,4 @@
2027 boardId?: string
2128 repoName?: string
2229}
30
Modified web/src/pages/workspace/ui/repo/repo-view.tsx View web/src/pages/workspace/ui/repo/repo-view.tsx
@@ -15,6 +15,7 @@
1515import { CommitsTab, CommitView } from '@/features/repo-history'
1616import { PullsTab, PullDetail } from '@/features/pull-request'
1717import { SettingsTab } from '@/features/repo-settings'
18import { PipelinesView } from '@/features/repo-pipelines'
1819import { RefPicker } from './ref-picker'
1920import { RepoAside } from './repo-aside'
2021import './repo-polish.css'
@@ -64,7 +65,7 @@
6465 browseRev,
6566 normalizeForRepo,
6667 } = useRepoViewLocation()
67 const { tab, rev, path, file: filePath, oid, line, commit, firstParent, diff, pull: pullRef, ptab, section } = view
68 const { tab, rev, path, file: filePath, oid, line, commit, firstParent, diff, pull: pullRef, ptab, run, section } = view
6869 const file = filePath ? { path: filePath, oid: oid || undefined } : null
6970 const [mode, setMode] = useState<RenderMode>('html')
7071 // Clone addresses come from the backend — never guessed from window.location.
@@ -260,6 +261,15 @@
260261 onChanged={() => setTick(t => t + 1)}
261262 />
262263 ) : null}
264 {tab === 'pipelines' ? (
265 <PipelinesView
266 space={spaceName}
267 repo={repoName}
268 defaultBranch={repo.default_branch}
269 runId={run}
270 onSelectRun={runId => patch({ run: runId })}
271 />
272 ) : null}
263273 {tab === 'settings' ? (
264274 <SettingsTab
265275 space={spaceName}
@@ -350,3 +360,4 @@
350360 </span>
351361 )
352362}
363
Modified web/src/shared/api/apiDefinitions.ts View web/src/shared/api/apiDefinitions.ts
@@ -216,6 +216,12 @@
216216 'chat.get_presence': ['GET', '/chat/users/{user_id}/presence'],
217217 'graphql.graphql_get': ['GET', '/graphql'],
218218 'graphql.graphql_post': ['POST', '/graphql'],
219 'imports.list_imports': ['GET', '/imports/{space_name}'],
220 'imports.create_import': ['POST', '/imports/{space_name}'],
221 'imports.probe_import': ['POST', '/imports/{space_name}/probe'],
222 'imports.get_import': ['GET', '/imports/{space_name}/{job_id}'],
223 'imports.cancel_import': ['POST', '/imports/{space_name}/{job_id}/cancel'],
224 'imports.retry_import': ['POST', '/imports/{space_name}/{job_id}/retry'],
219225 'issues.list_boards': ['GET', '/issues/{space_name}/boards'],
220226 'issues.create_board': ['POST', '/issues/{space_name}/boards'],
221227 'issues.get_board': ['GET', '/issues/{space_name}/boards/{board_id}'],
@@ -402,9 +408,14 @@
402408 'patches.patch_generate': ['GET', '/repos/{space_name}/{repo_name}/patch/generate'],
403409 'logs.path_log': ['GET', '/repos/{space_name}/{repo_name}/path-log'],
404410 'pipelines.list_runs': ['GET', '/repos/{space_name}/{repo_name}/pipelines'],
405 'pipelines.schedule': ['POST', '/repos/{space_name}/{repo_name}/pipelines/schedule'],
411 'pipelines.download_artifact': ['GET', '/repos/{space_name}/{repo_name}/pipelines/artifacts/{artifact_id}/download'],
406412 'pipelines.trigger': ['POST', '/repos/{space_name}/{repo_name}/pipelines/trigger'],
407 'pipelines.get_run': ['GET', '/repos/{space_name}/{repo_name}/pipelines/{job_id}'],
413 'pipelines.list_workflows': ['GET', '/repos/{space_name}/{repo_name}/pipelines/workflows'],
414 'pipelines.get_run': ['GET', '/repos/{space_name}/{repo_name}/pipelines/{run_id}'],
415 'pipelines.list_artifacts': ['GET', '/repos/{space_name}/{repo_name}/pipelines/{run_id}/artifacts'],
416 'pipelines.cancel_run': ['POST', '/repos/{space_name}/{repo_name}/pipelines/{run_id}/cancel'],
417 'pipelines.read_logs': ['GET', '/repos/{space_name}/{repo_name}/pipelines/{run_id}/jobs/{job_id}/logs'],
418 'pipelines.rerun': ['POST', '/repos/{space_name}/{repo_name}/pipelines/{run_id}/rerun'],
408419 'pulls.list_pulls': ['GET', '/repos/{space_name}/{repo_name}/pulls'],
409420 'pulls.create_pull': ['POST', '/repos/{space_name}/{repo_name}/pulls'],
410421 'pulls.get_pull': ['GET', '/repos/{space_name}/{repo_name}/pulls/{pull_ref}'],
@@ -460,6 +471,9 @@
460471 'remotes.upsert_remote': ['PUT', '/repos/{space_name}/{repo_name}/remotes/{name}'],
461472 'remotes.remove': ['DELETE', '/repos/{space_name}/{repo_name}/remotes/{name}'],
462473 'commits.rev_parse': ['GET', '/repos/{space_name}/{repo_name}/rev/{revision}'],
474 'pipelines.list_repo_secrets': ['GET', '/repos/{space_name}/{repo_name}/secrets'],
475 'pipelines.upsert_repo_secret': ['PUT', '/repos/{space_name}/{repo_name}/secrets/{name}'],
476 'pipelines.delete_repo_secret': ['DELETE', '/repos/{space_name}/{repo_name}/secrets/{name}'],
463477 'signatures.verify_commit': ['GET', '/repos/{space_name}/{repo_name}/signatures/commit/{sha}'],
464478 'signatures.verify_tag': ['GET', '/repos/{space_name}/{repo_name}/signatures/tag/{name}'],
465479 'repos.star_repo': ['PUT', '/repos/{space_name}/{repo_name}/star'],
@@ -467,6 +481,13 @@
467481 'repos.list_stargazers': ['GET', '/repos/{space_name}/{repo_name}/stargazers'],
468482 'submodules.submodule_list': ['GET', '/repos/{space_name}/{repo_name}/submodules'],
469483 'submodules.submodule_resolve': ['GET', '/repos/{space_name}/{repo_name}/submodules/resolve'],
484 'search.repo_symbols': ['GET', '/repos/{space_name}/{repo_name}/symbols'],
485 'search.symbol_references': ['GET', '/repos/{space_name}/{repo_name}/symbols/{symbol_name}/references'],
486 'tag_protection.list_policies': ['GET', '/repos/{space_name}/{repo_name}/tag-protection'],
487 'tag_protection.create_policy': ['POST', '/repos/{space_name}/{repo_name}/tag-protection'],
488 'tag_protection.get_policy': ['GET', '/repos/{space_name}/{repo_name}/tag-protection/{policy_id}'],
489 'tag_protection.delete_policy': ['DELETE', '/repos/{space_name}/{repo_name}/tag-protection/{policy_id}'],
490 'tag_protection.update_policy': ['PATCH', '/repos/{space_name}/{repo_name}/tag-protection/{policy_id}'],
470491 'tag_protection.list_tag_whitelists': [
471492 'GET',
472493 '/repos/{space_name}/{repo_name}/tag-protection/{policy_id}/whitelists'
@@ -487,6 +508,7 @@
487508 'repos.unarchive_repo': ['POST', '/repos/{space_name}/{repo_name}/unarchive'],
488509 'repos.upsert_watch': ['PUT', '/repos/{space_name}/{repo_name}/watch'],
489510 'repos.list_watchers': ['GET', '/repos/{space_name}/{repo_name}/watchers'],
511 'search.global_search': ['GET', '/search'],
490512 'space.list_spaces': ['GET', '/spaces'],
491513 'space.create_space': ['POST', '/spaces'],
492514 'space.accept_invite': ['POST', '/spaces/invites/accept'],
@@ -510,6 +532,8 @@
510532 'space.set_dir_user_overwrite': ['PUT', '/spaces/{space_name}/dir/{dir_id}/overwrites/users/{user_id}'],
511533 'space.remove_dir_user_overwrite': ['DELETE', '/spaces/{space_name}/dir/{dir_id}/overwrites/users/{user_id}'],
512534 'space.move_dir_node': ['PATCH', '/spaces/{space_name}/dir/{dir_id}/position'],
535 'pipelines.list_environments': ['GET', '/spaces/{space_name}/environments'],
536 'pipelines.list_deployments': ['GET', '/spaces/{space_name}/environments/{name}/deployments'],
513537 'space.list_groups': ['GET', '/spaces/{space_name}/groups'],
514538 'space.create_group': ['POST', '/spaces/{space_name}/groups'],
515539 'space.get_group': ['GET', '/spaces/{space_name}/groups/{group_id}'],
@@ -536,6 +560,7 @@
536560 'space.update_member': ['PATCH', '/spaces/{space_name}/members/{user_id}'],
537561 'space.assign_group': ['POST', '/spaces/{space_name}/members/{user_id}/groups'],
538562 'space.unassign_group': ['DELETE', '/spaces/{space_name}/members/{user_id}/groups/{group_id}'],
563 'pipelines.list_space_secrets': ['GET', '/spaces/{space_name}/secrets'],
539564 'topics.list_topics': ['GET', '/topics'],
540565 'topics.create_topic': ['POST', '/topics'],
541566 '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/build.rs View web/build.rs
@@ -53,10 +53,7 @@
5353 if rel == "index.html" {
5454 continue; // INDEX 已覆盖
5555 }
56 src.push_str(&format!(
57 " ({:?}, include_bytes!({:?})),\n",
58 rel, abs
59 ));
56 src.push_str(&format!(" ({:?}, include_bytes!({:?})),\n", rel, abs));
6057 }
6158 src.push_str("];\n\npub const PATHS: &[&str] = &[\n");
6259 for rel in files.keys() {
@@ -137,4 +134,4 @@
137134 out.insert(rel, path);
138135 }
139136 }
140}
No newline at end of file
137}
Modified web/lib.rs View web/lib.rs
@@ -61,7 +61,6 @@
6161 }
6262}
6363
64
6564pub fn paths() -> &'static [&'static str] {
6665 dist_embed::PATHS
6766}
@@ -130,12 +129,18 @@
130129
131130 #[test]
132131 fn lookup_with_and_without_leading_slash() {
133 let js = paths().iter().find(|p| p.ends_with(".js")).expect("js in dist");
132 let js = paths()
133 .iter()
134 .find(|p| p.ends_with(".js"))
135 .expect("js in dist");
134136 let a = file(&format!("/{js}")).unwrap();
135137 assert_eq!(a.path, *js);
136138 assert_eq!(a.mime, "text/javascript; charset=utf-8");
137139 assert_eq!(file(js).unwrap().content, a.content);
138 let css = paths().iter().find(|p| p.ends_with(".css")).expect("css in dist");
140 let css = paths()
141 .iter()
142 .find(|p| p.ends_with(".css"))
143 .expect("css in dist");
139144 assert_eq!(file(*css).unwrap().mime, "text/css; charset=utf-8");
140145 }
141146
Modified web/openapi.json View web/openapi.json

Diff too large to display.

Modified web/steiger.config.js View web/steiger.config.js
@@ -1,4 +1,4 @@
1import { relative } from 'node:path'
1import { relative, sep } from 'node:path'
22import fsd from '@feature-sliced/steiger-plugin'
33import { defineConfig } from 'steiger'
44
@@ -18,11 +18,17 @@
1818 'features/repo-branches',
1919 'features/repo-browse',
2020 'features/repo-history',
21 'features/repo-pipelines',
2122 'features/repo-settings',
23 'features/search',
2224 'features/space-management',
2325 'features/user-settings',
2426])
2527
28// 插件内部用 path.join() 拼路径:Windows 下 relative() 产出 `features\\chat`,/ 分隔的清单会静默失效。
29// 统一归一化为 POSIX 分隔符再比对,保证跨平台行为一致。
30const toPosixPath = (value) => value.split(sep).join('/')
31
2632const GUARDED_RULE_NAME = 'project/insignificant-slice'
2733const officialSliceRule = fsd.plugin.ruleDefinitions.find(({ name }) => name === 'fsd/insignificant-slice')
2834
@@ -37,7 +43,8 @@
3743 const { diagnostics } = await officialSliceRule.check(root, options)
3844 return {
3945 diagnostics: diagnostics.filter(
40 ({ location }) => !INSIGNIFICANT_SLICE_ALLOWLIST.has(relative(root.path, location.path)),
46 ({ location }) =>
47 !INSIGNIFICANT_SLICE_ALLOWLIST.has(toPosixPath(relative(root.path, location.path))),
4148 ),
4249 }
4350 },
Modified Cargo.lock View Cargo.lock
@@ -20,6 +20,7 @@
2020dependencies = [
2121 "auth",
2222 "axum",
23 "ci",
2324 "config",
2425 "export",
2526 "models",
@@ -1181,6 +1182,55 @@
11811182]
11821183
11831184[[package]]
1185name = "ci"
1186version = "1.0.0"
1187dependencies = [
1188 "aes-gcm 0.10.3",
1189 "async-trait",
1190 "auth",
1191 "axum",
1192 "base64 0.23.1",
1193 "ci-protocol",
1194 "config",
1195 "export",
1196 "git",
1197 "gitbaby",
1198 "globset",
1199 "hmac 0.13.0",
1200 "models",
1201 "rand 0.8.7",
1202 "reqwest 0.12.28",
1203 "sea-orm",
1204 "serde",
1205 "serde_json",
1206 "serde_yaml_ng",
1207 "session",
1208 "sha2 0.11.0",
1209 "space",
1210 "storage",
1211 "thiserror 2.0.20",
1212 "time 0.3.55",
1213 "tokio",
1214 "tracing",
1215 "utoipa",
1216 "uuid",
1217]
1218
1219[[package]]
1220name = "ci-protocol"
1221version = "1.0.0"
1222dependencies = [
1223 "globset",
1224 "serde",
1225 "serde_json",
1226 "serde_yaml_ng",
1227 "thiserror 2.0.20",
1228 "time 0.3.55",
1229 "utoipa",
1230 "uuid",
1231]
1232
1233[[package]]
11841234name = "cipher"
11851235version = "0.4.4"
11861236source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2656,6 +2706,7 @@
26562706 "base64 0.23.1",
26572707 "bytes",
26582708 "cache",
2709 "ci-protocol",
26592710 "config",
26602711 "export",
26612712 "flate2",
@@ -6365,6 +6416,8 @@
63656416version = "1.0.0"
63666417dependencies = [
63676418 "axum",
6419 "ci",
6420 "ci-protocol",
63686421 "config",
63696422 "export",
63706423 "git",
@@ -6870,6 +6923,28 @@
68706923]
68716924
68726925[[package]]
6926name = "runner"
6927version = "1.0.0"
6928dependencies = [
6929 "anyhow",
6930 "base64 0.23.1",
6931 "ci-protocol",
6932 "config",
6933 "dotenvy",
6934 "figment",
6935 "globset",
6936 "reqwest 0.12.28",
6937 "serde",
6938 "serde_json",
6939 "sha2 0.11.0",
6940 "time 0.3.55",
6941 "tokio",
6942 "tracing",
6943 "tracing-subscriber",
6944 "uuid",
6945]
6946
6947[[package]]
68736948name = "russh"
68746949version = "0.63.1"
68756950source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -7541,6 +7616,19 @@
75417616]
75427617
75437618[[package]]
7619name = "serde_yaml_ng"
7620version = "0.10.0"
7621source = "registry+https://github.com/rust-lang/crates.io-index"
7622checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f"
7623dependencies = [
7624 "indexmap",
7625 "itoa",
7626 "ryu",
7627 "serde",
7628 "unsafe-libyaml",
7629]
7630
7631[[package]]
75447632name = "serdect"
75457633version = "0.4.3"
75467634source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -8997,6 +9085,12 @@
89979085]
89989086
89999087[[package]]
9088name = "unsafe-libyaml"
9089version = "0.2.11"
9090source = "registry+https://github.com/rust-lang/crates.io-index"
9091checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
9092
9093[[package]]
90009094name = "untrusted"
90019095version = "0.7.1"
90029096source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -9283,6 +9377,7 @@
92839377 "axum-otel-metrics",
92849378 "axum_session",
92859379 "chat",
9380 "ci",
92869381 "config",
92879382 "export",
92889383 "git",
@@ -9305,6 +9400,31 @@
93059400]
93069401
93079402[[package]]
9403name = "webhook"
9404version = "1.0.0"
9405dependencies = [
9406 "anyhow",
9407 "axum",
9408 "ci",
9409 "ci-protocol",
9410 "config",
9411 "export",
9412 "git",
9413 "models",
9414 "reqwest 0.12.28",
9415 "sea-orm",
9416 "serde",
9417 "serde_json",
9418 "storage",
9419 "time 0.3.55",
9420 "tokio",
9421 "tokio-util",
9422 "tracing",
9423 "tracing-subscriber",
9424 "uuid",
9425]
9426
9427[[package]]
93089428name = "webpki-roots"
93099429version = "1.0.9"
93109430source = "registry+https://github.com/rust-lang/crates.io-index"
Modified Cargo.toml View Cargo.toml
@@ -20,6 +20,8 @@
2020auth = { path = "lib/auth" }
2121cache = { path = "lib/cache" }
2222chat = { path = "lib/chat" }
23ci-protocol = { path = "lib/ci-protocol" }
24ci = { path = "lib/ci" }
2325config = { path = "lib/config" }
2426email = { path = "lib/email" }
2527export = { path = "lib/export" }
@@ -104,3 +106,6 @@
104106tree-sitter-java = "0.23.5"
105107tree-sitter-c = "0.24.2"
106108tree-sitter-cpp = "0.23.4"
109
110
111serde_yaml_ng = "0.10"
Modified Dockerfile View Dockerfile
@@ -29,10 +29,17 @@
2929 --mount=type=cache,target=/build/target \
3030 cargo build --release --workspace --bins && \
3131 mkdir -p /out && \
32 for b in apihost githost synchost mails migrate openapi im adminhost; do \
32 for b in apihost githost synchost mails migrate openapi im adminhost webhook runner; do \
3333 cp "target/release/$b" /out/; \
3434 done
3535
36# The runner shells out to `docker run` for `container:` jobs; ship just the
37# static CLI so the image stays daemon-free.
38RUN curl -fsSL "https://mirrors.aliyun.com/docker-ce/linux/static/stable/x86_64/docker-27.3.1.tgz" -o /tmp/docker.tgz \
39 && tar -xzf /tmp/docker.tgz -C /tmp \
40 && cp /tmp/docker/docker /out/docker \
41 && rm -rf /tmp/docker /tmp/docker.tgz
42
3643########## runtime ##########
3744FROM debian:bookworm-slim
3845
@@ -44,16 +51,18 @@
4451 openssh-client \
4552 && rm -rf /var/lib/apt/lists/* \
4653 && useradd --system --create-home --uid 10001 gitdata \
47 && mkdir -p /opt/gitdata/repos /opt/gitdata/objects \
48 && chown -R gitdata:gitdata /opt/gitdata
54 && mkdir -p /opt/gitdata/repos /opt/gitdata/objects /var/lib/gitdata-runner \
55 && chown -R gitdata:gitdata /opt/gitdata /var/lib/gitdata-runner
4956
5057WORKDIR /app
5158
5259COPY --chown=gitdata:gitdata config/default.toml config/default.toml
53COPY --from=builder /out/apihost /out/githost /out/synchost /out/mails /out/migrate /out/openapi /out/im /out/adminhost /usr/local/bin/
60COPY --from=builder /out/apihost /out/githost /out/synchost /out/mails /out/migrate /out/openapi /out/im /out/adminhost /out/webhook /out/runner /usr/local/bin/
61COPY --from=builder /out/docker /usr/local/bin/docker
5462
5563USER gitdata
5664
57EXPOSE 3000 9418 22 3001 3002 3003 3004
65EXPOSE 3000 9418 22 3001 3002 3003 3004 3005
5866
5967CMD ["apihost"]
68
Modified openapi.json View openapi.json

Diff too large to display.