Skip to main content

max / makenotwork

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