//! Runner <-> forge JSON protocol. All types use snake_case field names and are serialized as JSON. //! //! Types that carry secrets (`RegisterRequest`, `RegisterResponse`, `JobAssignment`) implement //! `Debug` by hand and print `` for their secret fields, so they never end up in logs. use std::collections::BTreeMap; use std::fmt; use serde::{Deserialize, Serialize}; /// `POST /api/v1/ci/runners/register` #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RegisterRequest { /// Single-use registration token, as shown once by the admin page. pub token: String, pub name: String, pub labels: Vec, } impl fmt::Debug for RegisterRequest { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("RegisterRequest") .field("token", &"") .field("name", &self.name) .field("labels", &self.labels) .finish() } } /// Response to [`RegisterRequest`]. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RegisterResponse { pub runner_id: String, /// Bearer secret for all later runner calls. Shown to the runner once. pub runner_secret: String, } impl fmt::Debug for RegisterResponse { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("RegisterResponse") .field("runner_id", &self.runner_id) .field("runner_secret", &"") .finish() } } /// `POST /api/v1/ci/runners/claim`. Long-polls for up to 30s; 204 means nothing was claimed. /// The labels are informational: the server uses the labels stored for the runner. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ClaimRequest { pub labels: Vec, } /// Returned with 200 by the claim endpoint. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct JobAssignment { pub job_id: String, /// Bearer token for `/api/v1/ci/jobs/{id}/...` and for read-only git access. pub job_token: String, /// `owner/name`. pub repo: String, /// HTTPS clone URL of the repo. pub clone_url: String, pub commit: String, pub git_ref: String, pub job_name: String, pub image: String, pub run: String, pub env: BTreeMap, /// Secret values, by name. Never print these. pub secrets: BTreeMap, pub artifacts: Vec, pub timeout_minutes: u32, pub network: bool, } impl fmt::Debug for JobAssignment { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("JobAssignment") .field("job_id", &self.job_id) .field("job_token", &"") .field("repo", &self.repo) .field("clone_url", &self.clone_url) .field("commit", &self.commit) .field("git_ref", &self.git_ref) .field("job_name", &self.job_name) .field("image", &self.image) .field("run", &self.run) .field("env", &self.env) .field("secrets", &self.secrets.keys().collect::>()) .field("artifacts", &self.artifacts) .field("timeout_minutes", &self.timeout_minutes) .field("network", &self.network) .finish() } } /// Response to `POST /api/v1/ci/jobs/{id}/heartbeat`. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct HeartbeatResponse { pub cancel: bool, } /// `POST /api/v1/ci/jobs/{id}/finish`. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct FinishRequest { pub outcome: Outcome, pub exit_code: Option, pub message: Option, } /// Final result of a job, reported by the runner. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Outcome { Success, Failure, Cancelled, Error, } #[cfg(test)] mod tests { use super::*; #[test] fn outcome_is_snake_case() { let json = serde_json::to_string(&Outcome::Cancelled).unwrap(); assert_eq!(json, "\"cancelled\""); } #[test] fn finish_request_round_trips() { let req = FinishRequest { outcome: Outcome::Failure, exit_code: Some(2), message: None, }; let json = serde_json::to_string(&req).unwrap(); assert_eq!( json, r#"{"outcome":"failure","exit_code":2,"message":null}"# ); let back: FinishRequest = serde_json::from_str(&json).unwrap(); assert_eq!(back, req); } #[test] fn debug_redacts_secrets() { let mut secrets = BTreeMap::new(); secrets.insert("DEPLOY_KEY".to_string(), "hunter2-value".to_string()); let job = JobAssignment { job_id: "j1".into(), job_token: "tok-secret".into(), repo: "o/r".into(), clone_url: "https://example.test/o/r.git".into(), commit: "abc".into(), git_ref: "refs/heads/main".into(), job_name: "lint".into(), image: "docker.io/rust:1".into(), run: "true".into(), env: BTreeMap::new(), secrets, artifacts: vec![], timeout_minutes: 60, network: true, }; let dbg = format!("{job:?}"); assert!(!dbg.contains("hunter2-value")); assert!(!dbg.contains("tok-secret")); assert!(dbg.contains("DEPLOY_KEY")); } }