Skip to main content

max / makenotwork

15.5 KB · 544 lines History Blame Raw
1 //! Build pipeline management and trigger endpoints.
2 //!
3 //! Management endpoints use SyncKit JWT auth (app owner only).
4 //! Internal trigger endpoint uses Bearer token auth (BUILD_TRIGGER_TOKEN).
5
6 use axum::{
7 extract::{Path, State},
8 http::StatusCode,
9 response::IntoResponse,
10 routing::{get, post},
11 Json,
12 };
13 use chrono::{DateTime, Utc};
14 use serde::{Deserialize, Serialize};
15 use tower_governor::GovernorLayer;
16
17 use crate::{
18 constants,
19 csrf::{post_csrf_skip, with_csrf_skip, CsrfRouter},
20 db::{self, BuildConfigId, BuildId, BuildStatus, GitRepoId, OtaReleaseId, SyncAppId},
21 error::{AppError, Result},
22 synckit_auth::SyncUser,
23 AppState,
24 };
25
26 // ── Validation ──
27
28 /// Validate that all target strings are in the allowlist.
29 fn validate_targets(targets: &[String]) -> Result<()> {
30 if targets.is_empty() {
31 return Err(AppError::BadRequest("At least one target is required".to_string()));
32 }
33 for t in targets {
34 if !constants::BUILD_ALLOWED_TARGETS.contains(&t.as_str()) {
35 return Err(AppError::BadRequest(format!(
36 "Invalid target '{}'. Allowed: {}",
37 t,
38 constants::BUILD_ALLOWED_TARGETS.join(", ")
39 )));
40 }
41 }
42 Ok(())
43 }
44
45 /// Validate build_command and artifact_path for shell safety.
46 fn validate_build_config_fields(build_command: &str, artifact_path: &str) -> Result<()> {
47 crate::build_runner::validate_build_command(build_command)
48 .map_err(|e| AppError::validation(format!("build_command: {e}")))?;
49 crate::build_runner::validate_artifact_path(artifact_path)
50 .map_err(|e| AppError::validation(format!("artifact_path: {e}")))?;
51 Ok(())
52 }
53
54 /// Parse a tag like "v0.2.2" into a semver version string "0.2.2".
55 fn tag_to_version(tag: &str) -> Result<String> {
56 let version_str = tag.strip_prefix('v').unwrap_or(tag);
57 semver::Version::parse(version_str).map_err(|_| {
58 AppError::BadRequest(format!(
59 "Invalid version tag '{}'. Expected format: v0.2.2",
60 tag
61 ))
62 })?;
63 Ok(version_str.to_string())
64 }
65
66 /// Verify the authenticated user owns the given sync app.
67 async fn verify_app_owner(
68 state: &AppState,
69 sync_user: &SyncUser,
70 app_id: SyncAppId,
71 ) -> Result<db::DbSyncApp> {
72 let app = db::synckit::get_sync_app_by_id(&state.db, app_id)
73 .await?
74 .ok_or(AppError::NotFound)?;
75
76 if app.creator_id != sync_user.user_id {
77 return Err(AppError::Forbidden);
78 }
79
80 Ok(app)
81 }
82
83 // ── Request/Response types ──
84
85 #[derive(Deserialize)]
86 struct CreateConfigRequest {
87 repo_id: GitRepoId,
88 build_command: String,
89 artifact_path: String,
90 #[serde(default)]
91 signing_key_path: String,
92 #[serde(default = "default_targets")]
93 targets: Vec<String>,
94 }
95
96 fn default_targets() -> Vec<String> {
97 vec!["linux/x86_64".to_string(), "linux/aarch64".to_string()]
98 }
99
100 #[derive(Deserialize)]
101 struct UpdateConfigRequest {
102 build_command: String,
103 artifact_path: String,
104 #[serde(default)]
105 signing_key_path: String,
106 targets: Vec<String>,
107 #[serde(default = "default_true")]
108 enabled: bool,
109 }
110
111 fn default_true() -> bool {
112 true
113 }
114
115 #[derive(Serialize)]
116 struct ConfigResponse {
117 id: BuildConfigId,
118 app_id: SyncAppId,
119 repo_id: GitRepoId,
120 build_command: String,
121 artifact_path: String,
122 signing_key_path: String,
123 targets: Vec<String>,
124 enabled: bool,
125 created_at: DateTime<Utc>,
126 updated_at: DateTime<Utc>,
127 }
128
129 impl From<db::DbBuildConfig> for ConfigResponse {
130 fn from(c: db::DbBuildConfig) -> Self {
131 Self {
132 id: c.id,
133 app_id: c.app_id,
134 repo_id: c.repo_id,
135 build_command: c.build_command,
136 artifact_path: c.artifact_path,
137 signing_key_path: c.signing_key_path,
138 targets: c.targets,
139 enabled: c.enabled,
140 created_at: c.created_at,
141 updated_at: c.updated_at,
142 }
143 }
144 }
145
146 #[derive(Serialize)]
147 struct BuildResponse {
148 id: BuildId,
149 config_id: BuildConfigId,
150 app_id: SyncAppId,
151 version: String,
152 tag: String,
153 status: BuildStatus,
154 started_at: Option<DateTime<Utc>>,
155 finished_at: Option<DateTime<Utc>>,
156 log: String,
157 error_message: Option<String>,
158 release_id: Option<OtaReleaseId>,
159 triggered_by: String,
160 created_at: DateTime<Utc>,
161 }
162
163 impl From<db::DbBuild> for BuildResponse {
164 fn from(b: db::DbBuild) -> Self {
165 Self {
166 id: b.id,
167 config_id: b.config_id,
168 app_id: b.app_id,
169 version: b.version,
170 tag: b.tag,
171 status: b.status,
172 started_at: b.started_at,
173 finished_at: b.finished_at,
174 log: b.log,
175 error_message: b.error_message,
176 release_id: b.release_id,
177 triggered_by: b.triggered_by,
178 created_at: b.created_at,
179 }
180 }
181 }
182
183 #[derive(Deserialize)]
184 struct ManualTriggerRequest {
185 tag: String,
186 }
187
188 #[derive(Deserialize)]
189 struct HookTriggerRequest {
190 repo_owner: String,
191 repo_name: String,
192 tag: String,
193 }
194
195 // ── Management endpoints (SyncKit JWT auth) ──
196
197 /// Create a build config for an app.
198 ///
199 /// `POST /api/sync/builds/apps/{app_id}/config`
200 #[tracing::instrument(skip_all, name = "builds::create_config")]
201 async fn create_config(
202 State(state): State<AppState>,
203 sync_user: SyncUser,
204 Path(app_id): Path<SyncAppId>,
205 Json(req): Json<CreateConfigRequest>,
206 ) -> Result<impl IntoResponse> {
207 verify_app_owner(&state, &sync_user, app_id).await?;
208 validate_targets(&req.targets)?;
209 validate_build_config_fields(&req.build_command, &req.artifact_path)?;
210
211 // Verify repo ownership
212 let repo = db::git_repos::get_repo_by_id(&state.db, req.repo_id)
213 .await?
214 .ok_or(AppError::NotFound)?;
215 if repo.user_id != sync_user.user_id {
216 return Err(AppError::Forbidden);
217 }
218
219 let config = db::builds::create_build_config(
220 &state.db,
221 app_id,
222 req.repo_id,
223 &req.build_command,
224 &req.artifact_path,
225 &req.signing_key_path,
226 &req.targets,
227 )
228 .await?;
229
230 Ok((StatusCode::CREATED, Json(ConfigResponse::from(config))))
231 }
232
233 /// Get the build config for an app.
234 ///
235 /// `GET /api/sync/builds/apps/{app_id}/config`
236 #[tracing::instrument(skip_all, name = "builds::get_config")]
237 async fn get_config(
238 State(state): State<AppState>,
239 sync_user: SyncUser,
240 Path(app_id): Path<SyncAppId>,
241 ) -> Result<impl IntoResponse> {
242 verify_app_owner(&state, &sync_user, app_id).await?;
243
244 let config = db::builds::get_build_config_by_app(&state.db, app_id)
245 .await?
246 .ok_or(AppError::NotFound)?;
247
248 Ok(Json(ConfigResponse::from(config)))
249 }
250
251 /// Update the build config for an app.
252 ///
253 /// `PUT /api/sync/builds/apps/{app_id}/config`
254 #[tracing::instrument(skip_all, name = "builds::update_config")]
255 async fn update_config(
256 State(state): State<AppState>,
257 sync_user: SyncUser,
258 Path(app_id): Path<SyncAppId>,
259 Json(req): Json<UpdateConfigRequest>,
260 ) -> Result<impl IntoResponse> {
261 verify_app_owner(&state, &sync_user, app_id).await?;
262 validate_targets(&req.targets)?;
263 validate_build_config_fields(&req.build_command, &req.artifact_path)?;
264
265 let existing = db::builds::get_build_config_by_app(&state.db, app_id)
266 .await?
267 .ok_or(AppError::NotFound)?;
268
269 let config = db::builds::update_build_config(
270 &state.db,
271 existing.id,
272 &req.build_command,
273 &req.artifact_path,
274 &req.signing_key_path,
275 &req.targets,
276 req.enabled,
277 )
278 .await?;
279
280 Ok(Json(ConfigResponse::from(config)))
281 }
282
283 /// Delete the build config for an app (cascades to builds).
284 ///
285 /// `DELETE /api/sync/builds/apps/{app_id}/config`
286 #[tracing::instrument(skip_all, name = "builds::delete_config")]
287 async fn delete_config(
288 State(state): State<AppState>,
289 sync_user: SyncUser,
290 Path(app_id): Path<SyncAppId>,
291 ) -> Result<impl IntoResponse> {
292 verify_app_owner(&state, &sync_user, app_id).await?;
293
294 let config = db::builds::get_build_config_by_app(&state.db, app_id)
295 .await?
296 .ok_or(AppError::NotFound)?;
297
298 db::builds::delete_build_config(&state.db, config.id).await?;
299
300 Ok(StatusCode::NO_CONTENT)
301 }
302
303 /// Manually trigger a build for an app.
304 ///
305 /// `POST /api/sync/builds/apps/{app_id}/trigger`
306 #[tracing::instrument(skip_all, name = "builds::manual_trigger")]
307 async fn manual_trigger(
308 State(state): State<AppState>,
309 sync_user: SyncUser,
310 Path(app_id): Path<SyncAppId>,
311 Json(req): Json<ManualTriggerRequest>,
312 ) -> Result<impl IntoResponse> {
313 verify_app_owner(&state, &sync_user, app_id).await?;
314
315 let version = tag_to_version(&req.tag)?;
316
317 let config = db::builds::get_build_config_by_app(&state.db, app_id)
318 .await?
319 .ok_or_else(|| AppError::BadRequest("No build config found for this app".to_string()))?;
320
321 if !config.enabled {
322 return Err(AppError::BadRequest("Build config is disabled".to_string()));
323 }
324
325 if db::builds::has_active_build(&state.db, config.id).await? {
326 return Err(AppError::BadRequest(
327 "A build is already pending or running for this app".to_string(),
328 ));
329 }
330
331 let build =
332 db::builds::create_build(&state.db, config.id, app_id, &version, &req.tag, "manual")
333 .await?;
334
335 Ok((StatusCode::CREATED, Json(BuildResponse::from(build))))
336 }
337
338 /// List builds for an app.
339 ///
340 /// `GET /api/sync/builds/apps/{app_id}/builds`
341 #[tracing::instrument(skip_all, name = "builds::list_builds")]
342 async fn list_builds(
343 State(state): State<AppState>,
344 sync_user: SyncUser,
345 Path(app_id): Path<SyncAppId>,
346 ) -> Result<impl IntoResponse> {
347 verify_app_owner(&state, &sync_user, app_id).await?;
348
349 let builds =
350 db::builds::list_builds_by_app(&state.db, app_id, constants::BUILD_HISTORY_LIMIT).await?;
351 let response: Vec<BuildResponse> = builds.into_iter().map(BuildResponse::from).collect();
352
353 Ok(Json(response))
354 }
355
356 /// Get a single build with its log.
357 ///
358 /// `GET /api/sync/builds/apps/{app_id}/builds/{build_id}`
359 #[tracing::instrument(skip_all, name = "builds::get_build")]
360 async fn get_build(
361 State(state): State<AppState>,
362 sync_user: SyncUser,
363 Path((app_id, build_id)): Path<(SyncAppId, BuildId)>,
364 ) -> Result<impl IntoResponse> {
365 verify_app_owner(&state, &sync_user, app_id).await?;
366
367 let build = db::builds::get_build(&state.db, build_id)
368 .await?
369 .ok_or(AppError::NotFound)?;
370
371 if build.app_id != app_id {
372 return Err(AppError::NotFound);
373 }
374
375 Ok(Json(BuildResponse::from(build)))
376 }
377
378 /// Cancel a pending build.
379 ///
380 /// `POST /api/sync/builds/apps/{app_id}/builds/{build_id}/cancel`
381 #[tracing::instrument(skip_all, name = "builds::cancel_build")]
382 async fn cancel_build(
383 State(state): State<AppState>,
384 sync_user: SyncUser,
385 Path((app_id, build_id)): Path<(SyncAppId, BuildId)>,
386 ) -> Result<impl IntoResponse> {
387 verify_app_owner(&state, &sync_user, app_id).await?;
388
389 let build = db::builds::get_build(&state.db, build_id)
390 .await?
391 .ok_or(AppError::NotFound)?;
392
393 if build.app_id != app_id {
394 return Err(AppError::NotFound);
395 }
396
397 if build.status != BuildStatus::Pending {
398 return Err(AppError::BadRequest(
399 "Only pending builds can be cancelled".to_string(),
400 ));
401 }
402
403 db::builds::update_build_status(
404 &state.db,
405 build_id,
406 BuildStatus::Cancelled,
407 Some("Cancelled by user"),
408 )
409 .await?;
410
411 Ok(StatusCode::NO_CONTENT)
412 }
413
414 // ── Internal trigger (Bearer token auth) ──
415
416 /// Hook trigger: called by git post-receive hooks.
417 ///
418 /// `POST /api/internal/builds/trigger`
419 #[tracing::instrument(skip_all, name = "builds::hook_trigger")]
420 async fn hook_trigger(
421 State(state): State<AppState>,
422 headers: axum::http::HeaderMap,
423 Json(req): Json<HookTriggerRequest>,
424 ) -> Result<impl IntoResponse> {
425 // Authenticate via per-repo HMAC derived from BUILD_TRIGGER_TOKEN.
426 // The hook file contains HMAC(token, owner:repo), not the raw token.
427 let trigger_token = state
428 .config
429 .build_trigger_token
430 .as_deref()
431 .ok_or_else(|| AppError::ServiceUnavailable("Build triggers not configured".to_string()))?;
432
433 let auth_header = headers
434 .get("authorization")
435 .and_then(|v| v.to_str().ok())
436 .ok_or(AppError::Unauthorized)?;
437
438 let provided_hmac = auth_header
439 .strip_prefix("Bearer ")
440 .ok_or(AppError::Unauthorized)?;
441
442 let expected_hmac = crate::build_runner::repo_hmac(trigger_token, &req.repo_owner, &req.repo_name);
443 if !crate::helpers::constant_time_compare(provided_hmac, &expected_hmac) {
444 return Err(AppError::Unauthorized);
445 }
446
447 let version = tag_to_version(&req.tag)?;
448
449 // Look up repo by owner + name
450 let owner = db::users::get_user_by_username(
451 &state.db,
452 &db::Username::new(&req.repo_owner)
453 .map_err(|_| AppError::BadRequest("Invalid repo owner".to_string()))?,
454 )
455 .await?
456 .ok_or(AppError::NotFound)?;
457
458 let repo =
459 db::git_repos::get_repo_by_user_and_name(&state.db, owner.id, &req.repo_name)
460 .await?
461 .ok_or(AppError::NotFound)?;
462
463 // Find build config for this repo
464 let config = db::builds::get_build_config_by_repo(&state.db, repo.id)
465 .await?
466 .ok_or_else(|| {
467 AppError::BadRequest("No build config found for this repository".to_string())
468 })?;
469
470 if db::builds::has_active_build(&state.db, config.id).await? {
471 return Err(AppError::BadRequest(
472 "A build is already pending or running".to_string(),
473 ));
474 }
475
476 let build = db::builds::create_build(
477 &state.db,
478 config.id,
479 config.app_id,
480 &version,
481 &req.tag,
482 "tag",
483 )
484 .await?;
485
486 tracing::info!(
487 build_id = %build.id,
488 repo = %req.repo_name,
489 tag = %req.tag,
490 "build triggered by hook"
491 );
492
493 Ok((StatusCode::CREATED, Json(BuildResponse::from(build))))
494 }
495
496 // ── Router ──
497
498 /// Build the build pipeline route tree.
499 pub fn build_routes() -> CsrfRouter<AppState> {
500 let write_rate_limit = crate::helpers::rate_limiter_ms(
501 constants::BUILD_WRITE_RATE_LIMIT_MS,
502 constants::BUILD_WRITE_RATE_LIMIT_BURST,
503 );
504
505 const SYNC_SKIP: &str = "synckit builds: bearer auth, no session";
506 let mgmt_routes = CsrfRouter::new()
507 .route(
508 "/api/sync/builds/apps/{app_id}/config",
509 with_csrf_skip(SYNC_SKIP, post(create_config).get(get_config).put(update_config).delete(delete_config)),
510 )
511 .route(
512 "/api/sync/builds/apps/{app_id}/trigger",
513 post_csrf_skip(SYNC_SKIP, manual_trigger),
514 )
515 .route_get(
516 "/api/sync/builds/apps/{app_id}/builds",
517 get(list_builds),
518 )
519 .route_get(
520 "/api/sync/builds/apps/{app_id}/builds/{build_id}",
521 get(get_build),
522 )
523 .route(
524 "/api/sync/builds/apps/{app_id}/builds/{build_id}/cancel",
525 post_csrf_skip(SYNC_SKIP, cancel_build),
526 )
527 .route_layer(GovernorLayer {
528 config: write_rate_limit,
529 });
530
531 let trigger_rate_limit = crate::helpers::rate_limiter_per_sec(
532 constants::BUILD_TRIGGER_RATE_LIMIT_PER_SEC,
533 constants::BUILD_TRIGGER_RATE_LIMIT_BURST,
534 );
535
536 let internal_routes = CsrfRouter::new()
537 .route("/api/internal/builds/trigger", post_csrf_skip("internal CI hook: HMAC bearer auth", hook_trigger))
538 .route_layer(GovernorLayer {
539 config: trigger_rate_limit,
540 });
541
542 mgmt_routes.merge(internal_routes)
543 }
544