gitlyx / ci / ci_routes.v
309 lines · 263 sloc · 7.72 KB · 13ca859655a005b2a87a4935764694302b4f2fda
Raw
1module main
2
3import veb
4import api
5import x.json2 as json
6import net.http
7import time
8import git
9import crypto.hmac
10import crypto.sha256
11import encoding.hex
12
13struct CiStatusCallback {
14 run_id string
15 repo_id string
16 commit_hash string
17 branch string
18 status string
19}
20
21// POST /api/v1/ci/status - Callback endpoint for gitly_ci to report status updates
22@['/api/v1/ci/status'; post]
23pub fn (mut app App) handle_ci_status_callback() veb.Result {
24 body := ctx.req.data
25 if !app.verify_ci_callback_signature(ctx, body) {
26 ctx.res.set_status(.unauthorized)
27 return ctx.json_error('Invalid or missing CI callback signature')
28 }
29 callback := json.decode[CiStatusCallback](body) or {
30 return ctx.json_error('Invalid request body')
31 }
32
33 repo_id := callback.repo_id.int()
34 ci_run_id := callback.run_id.int()
35 status := ci_status_from_string(callback.status)
36
37 app.upsert_ci_status(repo_id, callback.commit_hash, callback.branch, status, ci_run_id) or {
38 return ctx.json_error('Failed to update CI status: ${err}')
39 }
40
41 return ctx.json(api.ApiSuccessResponse[string]{
42 success: true
43 result: 'ok'
44 })
45}
46
47// verify_ci_callback_signature checks the HMAC-SHA256 signature of the raw
48// callback body against the shared ci_secret, using a constant-time compare.
49// When no secret is configured it allows the request (preserving the previous
50// behaviour) but logs a warning; set `ci_secret` in both gitly and gitly_ci to
51// require signed callbacks and stop anyone from spoofing CI status.
52fn (mut app App) verify_ci_callback_signature(ctx &Context, body string) bool {
53 secret := app.config.ci_secret
54 if secret == '' {
55 app.warn('CI status callback accepted WITHOUT authentication; set ci_secret in gitly and gitly_ci to require signed callbacks')
56 return true
57 }
58 provided := ctx.get_custom_header('X-Gitly-CI-Signature') or { return false }
59 mac := hmac.new(secret.bytes(), body.bytes(), sha256.sum, sha256.block_size)
60 expected := 'sha256=' + hex.encode(mac)
61 return hmac.equal(provided.bytes(), expected.bytes())
62}
63
64// GET /:username/:repo_name/ci - CI runs list page
65@['/:username/:repo_name/ci']
66pub fn (mut app App) ci_runs(username string, repo_name string) veb.Result {
67 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
68
69 if !repo.is_public {
70 if repo.user_id != ctx.user.id {
71 return ctx.not_found()
72 }
73 }
74
75 // Check if .gitly-ci.yml exists in the repo
76 has_ci_file := git.Git.exec_in_dir(repo.git_dir,
77 ['show', '${repo.primary_branch}:.gitly-ci.yml']).exit_code == 0
78
79 // Fetch runs from gitly_ci service for a complete list
80 mut ci_runs := []CiRunListItem{}
81 mut ci_service_error := false
82 if app.config.ci_service_url != '' {
83 runs_url := '${app.config.ci_service_url}/api/v1/runs/repo/${repo.id}'
84 response := http.get(runs_url) or {
85 ci_service_error = true
86 http.Response{}
87 }
88 if !ci_service_error && response.status_code == 200 {
89 runs_resp := json.decode[CiApiRunListResponse](response.body) or {
90 CiApiRunListResponse{}
91 }
92 if runs_resp.success {
93 for r in runs_resp.result {
94 ci_runs << CiRunListItem{
95 ci_run_id: r.id
96 status: ci_status_from_string(r.status)
97 commit_hash: r.commit_hash
98 branch: r.branch
99 created_at: r.created_at
100 finished_at: r.finished_at
101 }
102 }
103 }
104 } else if !ci_service_error && response.status_code != 200 {
105 ci_service_error = true
106 }
107 }
108
109 return $veb.html()
110}
111
112// GET /:username/:repo_name/ci/:run_id_str - CI run detail page
113@['/:username/:repo_name/ci/:run_id_str']
114pub fn (mut app App) ci_run_detail(username string, repo_name string, run_id_str string) veb.Result {
115 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
116
117 if !repo.is_public {
118 if repo.user_id != ctx.user.id {
119 return ctx.not_found()
120 }
121 }
122
123 ci_run_id := run_id_str.int()
124
125 // Fetch run details from gitly_ci service
126 if app.config.ci_service_url == '' {
127 return ctx.not_found()
128 }
129
130 ci_url := '${app.config.ci_service_url}/api/v1/runs/${ci_run_id}'
131 response := http.get(ci_url) or { return ctx.not_found() }
132
133 if response.status_code != 200 {
134 return ctx.not_found()
135 }
136
137 ci_run_json := response.body
138
139 // Parse the response to display
140 run_data := json.decode[CiApiRunResponse](ci_run_json) or { return ctx.not_found() }
141
142 ci_run := run_data.result
143
144 return $veb.html()
145}
146
147// POST /:username/:repo_name/ci/:run_id_str/restart - Restart a CI run
148@['/:username/:repo_name/ci/:run_id_str/restart'; post]
149pub fn (mut app App) ci_restart_run(username string, repo_name string, run_id_str string) veb.Result {
150 repo := app.find_repo_by_name_and_username(repo_name, username) or { return ctx.not_found() }
151
152 // Only repo owner can restart
153 if repo.user_id != ctx.user.id {
154 return ctx.not_found()
155 }
156
157 ci_run_id := run_id_str.int()
158
159 if app.config.ci_service_url == '' {
160 return ctx.not_found()
161 }
162
163 // Call gitly_ci restart API
164 restart_url := '${app.config.ci_service_url}/api/v1/runs/${ci_run_id}/restart'
165 response := http.post(restart_url, '') or { return ctx.not_found() }
166
167 if response.status_code != 200 {
168 return ctx.not_found()
169 }
170
171 result := json.decode[CiApiRunResponse](response.body) or { return ctx.not_found() }
172
173 if result.success {
174 new_run := result.result
175 // Update local CI status
176 app.upsert_ci_status(repo.id, new_run.commit_hash, new_run.branch, .pending, new_run.id) or {}
177 // Redirect to new run
178 return ctx.redirect('/${username}/${repo_name}/ci/${new_run.id}')
179 }
180
181 return ctx.redirect('/${username}/${repo_name}/ci/${ci_run_id}')
182}
183
184// Structs for parsing gitly_ci API responses
185
186struct CiApiRunListResponse {
187 success bool
188 result []CiRunListResponseItem
189}
190
191struct CiRunListResponseItem {
192 id int
193 status string
194 commit_hash string
195 branch string
196 created_at int
197 finished_at int
198}
199
200struct CiRunListItem {
201 ci_run_id int
202 status CiStatusEnum
203 commit_hash string
204 branch string
205 created_at int
206 finished_at int
207}
208
209fn (ci &CiRunListItem) relative_time() string {
210 if ci.finished_at > 0 {
211 return time.unix(ci.finished_at).relative()
212 }
213 if ci.created_at > 0 {
214 return time.unix(ci.created_at).relative()
215 }
216 return ''
217}
218
219struct CiApiRunResponse {
220 success bool
221 result CiRunDetail
222}
223
224struct CiRunDetail {
225 id int
226 status string
227 commit_hash string
228 branch string
229 created_at int
230 finished_at int
231 jobs []CiJobDetail
232}
233
234struct CiJobDetail {
235 id int
236 name string
237 status string
238 exit_code int
239 started_at int
240 finished_at int
241 steps []CiStepDetail
242}
243
244struct CiStepDetail {
245 id int
246 name string
247 command string
248 status string
249 output string
250 exit_code int
251}
252
253fn (r &CiRunDetail) status_css_class() string {
254 return match r.status {
255 'success' { 'ci-success' }
256 'failure' { 'ci-failure' }
257 'running' { 'ci-running' }
258 'cancelled' { 'ci-cancelled' }
259 else { 'ci-pending' }
260 }
261}
262
263fn (r &CiRunDetail) created_relative() string {
264 if r.created_at == 0 {
265 return ''
266 }
267 return time.unix(r.created_at).relative()
268}
269
270fn (r &CiRunDetail) duration() string {
271 if r.finished_at == 0 || r.created_at == 0 {
272 return 'running...'
273 }
274 d := r.finished_at - r.created_at
275 if d < 60 {
276 return '${d}s'
277 }
278 return '${d / 60}m ${d % 60}s'
279}
280
281fn (j &CiJobDetail) status_css_class() string {
282 return match j.status {
283 'success' { 'ci-success' }
284 'failure' { 'ci-failure' }
285 'running' { 'ci-running' }
286 'cancelled' { 'ci-cancelled' }
287 else { 'ci-pending' }
288 }
289}
290
291fn (s &CiStepDetail) status_css_class() string {
292 return match s.status {
293 'success' { 'ci-success' }
294 'failure' { 'ci-failure' }
295 'running' { 'ci-running' }
296 'cancelled' { 'ci-cancelled' }
297 else { 'ci-pending' }
298 }
299}
300
301fn (s &CiStepDetail) status_icon() string {
302 return match s.status {
303 'success' { '✓' }
304 'failure' { '✗' }
305 'running' { '⟳' }
306 'cancelled' { '⊘' }
307 else { '○' }
308 }
309}
310