Skip to main content

max / makenotwork

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