//! Build pipeline management and trigger endpoints. //! //! Management endpoints use SyncKit JWT auth (app owner only). //! Internal trigger endpoint uses Bearer token auth (BUILD_TRIGGER_TOKEN). use axum::{ extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::{get, post}, Json, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tower_governor::GovernorLayer; use crate::{ constants, csrf::{post_csrf_skip, with_csrf_skip, CsrfRouter}, db::{self, BuildConfigId, BuildId, BuildStatus, GitRepoId, OtaReleaseId, SyncAppId}, error::{AppError, Result}, synckit_auth::SyncUser, AppState, }; // ── Validation ── /// Validate that all target strings are in the allowlist. fn validate_targets(targets: &[String]) -> Result<()> { if targets.is_empty() { return Err(AppError::BadRequest("At least one target is required".to_string())); } for t in targets { if !constants::BUILD_ALLOWED_TARGETS.contains(&t.as_str()) { return Err(AppError::BadRequest(format!( "Invalid target '{}'. Allowed: {}", t, constants::BUILD_ALLOWED_TARGETS.join(", ") ))); } } Ok(()) } /// Validate build_command and artifact_path for shell safety. fn validate_build_config_fields(build_command: &str, artifact_path: &str) -> Result<()> { crate::build_runner::validate_build_command(build_command) .map_err(|e| AppError::validation(format!("build_command: {e}")))?; crate::build_runner::validate_artifact_path(artifact_path) .map_err(|e| AppError::validation(format!("artifact_path: {e}")))?; Ok(()) } /// Parse a tag like "v0.2.2" into a semver version string "0.2.2". fn tag_to_version(tag: &str) -> Result { let version_str = tag.strip_prefix('v').unwrap_or(tag); semver::Version::parse(version_str).map_err(|_| { AppError::BadRequest(format!( "Invalid version tag '{}'. Expected format: v0.2.2", tag )) })?; Ok(version_str.to_string()) } /// Verify the authenticated user owns the given sync app. async fn verify_app_owner( state: &AppState, sync_user: &SyncUser, app_id: SyncAppId, ) -> Result { let app = db::synckit::get_sync_app_by_id(&state.db, app_id) .await? .ok_or(AppError::NotFound)?; if app.creator_id != sync_user.user_id { return Err(AppError::Forbidden); } Ok(app) } // ── Request/Response types ── #[derive(Deserialize)] struct CreateConfigRequest { repo_id: GitRepoId, build_command: String, artifact_path: String, #[serde(default)] signing_key_path: String, #[serde(default = "default_targets")] targets: Vec, } fn default_targets() -> Vec { vec!["linux/x86_64".to_string(), "linux/aarch64".to_string()] } #[derive(Deserialize)] struct UpdateConfigRequest { build_command: String, artifact_path: String, #[serde(default)] signing_key_path: String, targets: Vec, #[serde(default = "default_true")] enabled: bool, } fn default_true() -> bool { true } #[derive(Serialize)] struct ConfigResponse { id: BuildConfigId, app_id: SyncAppId, repo_id: GitRepoId, build_command: String, artifact_path: String, signing_key_path: String, targets: Vec, enabled: bool, created_at: DateTime, updated_at: DateTime, } impl From for ConfigResponse { fn from(c: db::DbBuildConfig) -> Self { Self { id: c.id, app_id: c.app_id, repo_id: c.repo_id, build_command: c.build_command, artifact_path: c.artifact_path, signing_key_path: c.signing_key_path, targets: c.targets, enabled: c.enabled, created_at: c.created_at, updated_at: c.updated_at, } } } #[derive(Serialize)] struct BuildResponse { id: BuildId, config_id: BuildConfigId, app_id: SyncAppId, version: String, tag: String, status: BuildStatus, started_at: Option>, finished_at: Option>, log: String, error_message: Option, release_id: Option, triggered_by: String, created_at: DateTime, } impl From for BuildResponse { fn from(b: db::DbBuild) -> Self { Self { id: b.id, config_id: b.config_id, app_id: b.app_id, version: b.version, tag: b.tag, status: b.status, started_at: b.started_at, finished_at: b.finished_at, log: b.log, error_message: b.error_message, release_id: b.release_id, triggered_by: b.triggered_by, created_at: b.created_at, } } } #[derive(Deserialize)] struct ManualTriggerRequest { tag: String, } #[derive(Deserialize)] struct HookTriggerRequest { repo_owner: String, repo_name: String, tag: String, } // ── Management endpoints (SyncKit JWT auth) ── /// Create a build config for an app. /// /// `POST /api/sync/builds/apps/{app_id}/config` #[tracing::instrument(skip_all, name = "builds::create_config")] async fn create_config( State(state): State, sync_user: SyncUser, Path(app_id): Path, Json(req): Json, ) -> Result { verify_app_owner(&state, &sync_user, app_id).await?; validate_targets(&req.targets)?; validate_build_config_fields(&req.build_command, &req.artifact_path)?; // Verify repo ownership let repo = db::git_repos::get_repo_by_id(&state.db, req.repo_id) .await? .ok_or(AppError::NotFound)?; if repo.user_id != sync_user.user_id { return Err(AppError::Forbidden); } let config = db::builds::create_build_config( &state.db, app_id, req.repo_id, &req.build_command, &req.artifact_path, &req.signing_key_path, &req.targets, ) .await?; Ok((StatusCode::CREATED, Json(ConfigResponse::from(config)))) } /// Get the build config for an app. /// /// `GET /api/sync/builds/apps/{app_id}/config` #[tracing::instrument(skip_all, name = "builds::get_config")] async fn get_config( State(state): State, sync_user: SyncUser, Path(app_id): Path, ) -> Result { verify_app_owner(&state, &sync_user, app_id).await?; let config = db::builds::get_build_config_by_app(&state.db, app_id) .await? .ok_or(AppError::NotFound)?; Ok(Json(ConfigResponse::from(config))) } /// Update the build config for an app. /// /// `PUT /api/sync/builds/apps/{app_id}/config` #[tracing::instrument(skip_all, name = "builds::update_config")] async fn update_config( State(state): State, sync_user: SyncUser, Path(app_id): Path, Json(req): Json, ) -> Result { verify_app_owner(&state, &sync_user, app_id).await?; validate_targets(&req.targets)?; validate_build_config_fields(&req.build_command, &req.artifact_path)?; let existing = db::builds::get_build_config_by_app(&state.db, app_id) .await? .ok_or(AppError::NotFound)?; let config = db::builds::update_build_config( &state.db, existing.id, &req.build_command, &req.artifact_path, &req.signing_key_path, &req.targets, req.enabled, ) .await?; Ok(Json(ConfigResponse::from(config))) } /// Delete the build config for an app (cascades to builds). /// /// `DELETE /api/sync/builds/apps/{app_id}/config` #[tracing::instrument(skip_all, name = "builds::delete_config")] async fn delete_config( State(state): State, sync_user: SyncUser, Path(app_id): Path, ) -> Result { verify_app_owner(&state, &sync_user, app_id).await?; let config = db::builds::get_build_config_by_app(&state.db, app_id) .await? .ok_or(AppError::NotFound)?; db::builds::delete_build_config(&state.db, config.id).await?; Ok(StatusCode::NO_CONTENT) } /// Manually trigger a build for an app. /// /// `POST /api/sync/builds/apps/{app_id}/trigger` #[tracing::instrument(skip_all, name = "builds::manual_trigger")] async fn manual_trigger( State(state): State, sync_user: SyncUser, Path(app_id): Path, Json(req): Json, ) -> Result { verify_app_owner(&state, &sync_user, app_id).await?; let version = tag_to_version(&req.tag)?; let config = db::builds::get_build_config_by_app(&state.db, app_id) .await? .ok_or_else(|| AppError::BadRequest("No build config found for this app".to_string()))?; if !config.enabled { return Err(AppError::BadRequest("Build config is disabled".to_string())); } if db::builds::has_active_build(&state.db, config.id).await? { return Err(AppError::BadRequest( "A build is already pending or running for this app".to_string(), )); } let build = db::builds::create_build(&state.db, config.id, app_id, &version, &req.tag, "manual") .await?; Ok((StatusCode::CREATED, Json(BuildResponse::from(build)))) } /// List builds for an app. /// /// `GET /api/sync/builds/apps/{app_id}/builds` #[tracing::instrument(skip_all, name = "builds::list_builds")] async fn list_builds( State(state): State, sync_user: SyncUser, Path(app_id): Path, ) -> Result { verify_app_owner(&state, &sync_user, app_id).await?; let builds = db::builds::list_builds_by_app(&state.db, app_id, constants::BUILD_HISTORY_LIMIT).await?; let response: Vec = builds.into_iter().map(BuildResponse::from).collect(); Ok(Json(response)) } /// Get a single build with its log. /// /// `GET /api/sync/builds/apps/{app_id}/builds/{build_id}` #[tracing::instrument(skip_all, name = "builds::get_build")] async fn get_build( State(state): State, sync_user: SyncUser, Path((app_id, build_id)): Path<(SyncAppId, BuildId)>, ) -> Result { verify_app_owner(&state, &sync_user, app_id).await?; let build = db::builds::get_build(&state.db, build_id) .await? .ok_or(AppError::NotFound)?; if build.app_id != app_id { return Err(AppError::NotFound); } Ok(Json(BuildResponse::from(build))) } /// Cancel a pending build. /// /// `POST /api/sync/builds/apps/{app_id}/builds/{build_id}/cancel` #[tracing::instrument(skip_all, name = "builds::cancel_build")] async fn cancel_build( State(state): State, sync_user: SyncUser, Path((app_id, build_id)): Path<(SyncAppId, BuildId)>, ) -> Result { verify_app_owner(&state, &sync_user, app_id).await?; let build = db::builds::get_build(&state.db, build_id) .await? .ok_or(AppError::NotFound)?; if build.app_id != app_id { return Err(AppError::NotFound); } if build.status != BuildStatus::Pending { return Err(AppError::BadRequest( "Only pending builds can be cancelled".to_string(), )); } db::builds::update_build_status( &state.db, build_id, BuildStatus::Cancelled, Some("Cancelled by user"), ) .await?; Ok(StatusCode::NO_CONTENT) } // ── Internal trigger (Bearer token auth) ── /// Hook trigger: called by git post-receive hooks. /// /// `POST /api/internal/builds/trigger` #[tracing::instrument(skip_all, name = "builds::hook_trigger")] async fn hook_trigger( State(state): State, headers: axum::http::HeaderMap, Json(req): Json, ) -> Result { // Authenticate via per-repo HMAC derived from BUILD_TRIGGER_TOKEN. // The hook file contains HMAC(token, owner:repo), not the raw token. let trigger_token = state .config .build_trigger_token .as_deref() .ok_or_else(|| AppError::ServiceUnavailable("Build triggers not configured".to_string()))?; let auth_header = headers .get("authorization") .and_then(|v| v.to_str().ok()) .ok_or(AppError::Unauthorized)?; let provided_hmac = auth_header .strip_prefix("Bearer ") .ok_or(AppError::Unauthorized)?; let expected_hmac = crate::build_runner::repo_hmac(trigger_token, &req.repo_owner, &req.repo_name); if !crate::helpers::constant_time_compare(provided_hmac, &expected_hmac) { return Err(AppError::Unauthorized); } let version = tag_to_version(&req.tag)?; // Look up repo by owner + name let owner = db::users::get_user_by_username( &state.db, &db::Username::new(&req.repo_owner) .map_err(|_| AppError::BadRequest("Invalid repo owner".to_string()))?, ) .await? .ok_or(AppError::NotFound)?; let repo = db::git_repos::get_repo_by_user_and_name(&state.db, owner.id, &req.repo_name) .await? .ok_or(AppError::NotFound)?; // Find build config for this repo let config = db::builds::get_build_config_by_repo(&state.db, repo.id) .await? .ok_or_else(|| { AppError::BadRequest("No build config found for this repository".to_string()) })?; if db::builds::has_active_build(&state.db, config.id).await? { return Err(AppError::BadRequest( "A build is already pending or running".to_string(), )); } let build = db::builds::create_build( &state.db, config.id, config.app_id, &version, &req.tag, "tag", ) .await?; tracing::info!( build_id = %build.id, repo = %req.repo_name, tag = %req.tag, "build triggered by hook" ); Ok((StatusCode::CREATED, Json(BuildResponse::from(build)))) } // ── Router ── /// Build the build pipeline route tree. pub fn build_routes() -> CsrfRouter { let write_rate_limit = crate::helpers::rate_limiter_ms( constants::BUILD_WRITE_RATE_LIMIT_MS, constants::BUILD_WRITE_RATE_LIMIT_BURST, ); const SYNC_SKIP: &str = "synckit builds: bearer auth, no session"; let mgmt_routes = CsrfRouter::new() .route( "/api/sync/builds/apps/{app_id}/config", with_csrf_skip(SYNC_SKIP, post(create_config).get(get_config).put(update_config).delete(delete_config)), ) .route( "/api/sync/builds/apps/{app_id}/trigger", post_csrf_skip(SYNC_SKIP, manual_trigger), ) .route_get( "/api/sync/builds/apps/{app_id}/builds", get(list_builds), ) .route_get( "/api/sync/builds/apps/{app_id}/builds/{build_id}", get(get_build), ) .route( "/api/sync/builds/apps/{app_id}/builds/{build_id}/cancel", post_csrf_skip(SYNC_SKIP, cancel_build), ) .route_layer(GovernorLayer { config: write_rate_limit, }); let trigger_rate_limit = crate::helpers::rate_limiter_per_sec( constants::BUILD_TRIGGER_RATE_LIMIT_PER_SEC, constants::BUILD_TRIGGER_RATE_LIMIT_BURST, ); let internal_routes = CsrfRouter::new() .route("/api/internal/builds/trigger", post_csrf_skip("internal CI hook: HMAC bearer auth", hook_trigger)) .route_layer(GovernorLayer { config: trigger_rate_limit, }); mgmt_routes.merge(internal_routes) }