forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
5.4 KiB
1//! Runner <-> forge JSON protocol. All types use snake_case field names and are serialized as JSON.
2//!
3//! Types that carry secrets (`RegisterRequest`, `RegisterResponse`, `JobAssignment`) implement
4//! `Debug` by hand and print `<redacted>` for their secret fields, so they never end up in logs.
5
6use std::collections::BTreeMap;
7use std::fmt;
8
9use serde::{Deserialize, Serialize};
10
11/// `POST /api/v1/ci/runners/register`
12#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct RegisterRequest {
14 /// Single-use registration token, as shown once by the admin page.
15 pub token: String,
16 pub name: String,
17 pub labels: Vec<String>,
18}
19
20impl fmt::Debug for RegisterRequest {
21 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22 f.debug_struct("RegisterRequest")
23 .field("token", &"<redacted>")
24 .field("name", &self.name)
25 .field("labels", &self.labels)
26 .finish()
27 }
28}
29
30/// Response to [`RegisterRequest`].
31#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct RegisterResponse {
33 pub runner_id: String,
34 /// Bearer secret for all later runner calls. Shown to the runner once.
35 pub runner_secret: String,
36}
37
38impl fmt::Debug for RegisterResponse {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 f.debug_struct("RegisterResponse")
41 .field("runner_id", &self.runner_id)
42 .field("runner_secret", &"<redacted>")
43 .finish()
44 }
45}
46
47/// `POST /api/v1/ci/runners/claim`. Long-polls for up to 30s; 204 means nothing was claimed.
48/// The labels are informational: the server uses the labels stored for the runner.
49#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ClaimRequest {
51 pub labels: Vec<String>,
52}
53
54/// Returned with 200 by the claim endpoint.
55#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct JobAssignment {
57 pub job_id: String,
58 /// Bearer token for `/api/v1/ci/jobs/{id}/...` and for read-only git access.
59 pub job_token: String,
60 /// `owner/name`.
61 pub repo: String,
62 /// HTTPS clone URL of the repo.
63 pub clone_url: String,
64 pub commit: String,
65 pub git_ref: String,
66 pub job_name: String,
67 pub image: String,
68 pub run: String,
69 pub env: BTreeMap<String, String>,
70 /// Secret values, by name. Never print these.
71 pub secrets: BTreeMap<String, String>,
72 pub artifacts: Vec<String>,
73 pub timeout_minutes: u32,
74 pub network: bool,
75}
76
77impl fmt::Debug for JobAssignment {
78 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79 f.debug_struct("JobAssignment")
80 .field("job_id", &self.job_id)
81 .field("job_token", &"<redacted>")
82 .field("repo", &self.repo)
83 .field("clone_url", &self.clone_url)
84 .field("commit", &self.commit)
85 .field("git_ref", &self.git_ref)
86 .field("job_name", &self.job_name)
87 .field("image", &self.image)
88 .field("run", &self.run)
89 .field("env", &self.env)
90 .field("secrets", &self.secrets.keys().collect::<Vec<_>>())
91 .field("artifacts", &self.artifacts)
92 .field("timeout_minutes", &self.timeout_minutes)
93 .field("network", &self.network)
94 .finish()
95 }
96}
97
98/// Response to `POST /api/v1/ci/jobs/{id}/heartbeat`.
99#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
100pub struct HeartbeatResponse {
101 pub cancel: bool,
102}
103
104/// `POST /api/v1/ci/jobs/{id}/finish`.
105#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
106pub struct FinishRequest {
107 pub outcome: Outcome,
108 pub exit_code: Option<i32>,
109 pub message: Option<String>,
110}
111
112/// Final result of a job, reported by the runner.
113#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum Outcome {
116 Success,
117 Failure,
118 Cancelled,
119 Error,
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn outcome_is_snake_case() {
128 let json = serde_json::to_string(&Outcome::Cancelled).unwrap();
129 assert_eq!(json, "\"cancelled\"");
130 }
131
132 #[test]
133 fn finish_request_round_trips() {
134 let req = FinishRequest {
135 outcome: Outcome::Failure,
136 exit_code: Some(2),
137 message: None,
138 };
139 let json = serde_json::to_string(&req).unwrap();
140 assert_eq!(
141 json,
142 r#"{"outcome":"failure","exit_code":2,"message":null}"#
143 );
144 let back: FinishRequest = serde_json::from_str(&json).unwrap();
145 assert_eq!(back, req);
146 }
147
148 #[test]
149 fn debug_redacts_secrets() {
150 let mut secrets = BTreeMap::new();
151 secrets.insert("DEPLOY_KEY".to_string(), "hunter2-value".to_string());
152 let job = JobAssignment {
153 job_id: "j1".into(),
154 job_token: "tok-secret".into(),
155 repo: "o/r".into(),
156 clone_url: "https://example.test/o/r.git".into(),
157 commit: "abc".into(),
158 git_ref: "refs/heads/main".into(),
159 job_name: "lint".into(),
160 image: "docker.io/rust:1".into(),
161 run: "true".into(),
162 env: BTreeMap::new(),
163 secrets,
164 artifacts: vec![],
165 timeout_minutes: 60,
166 network: true,
167 };
168 let dbg = format!("{job:?}");
169 assert!(!dbg.contains("hunter2-value"));
170 assert!(!dbg.contains("tok-secret"));
171 assert!(dbg.contains("DEPLOY_KEY"));
172 }
173}