Skip to main content

max / makenotwork

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