runner_manager_github/jit.rs
1// owner: c4-demand-and-jit-gateway
2
3//! Just-in-time runner registration: the one call in this product that returns
4//! a secret.
5//!
6//! ```text
7//! POST /repos/{owner}/{repo}/actions/runners/generate-jitconfig
8//! POST /orgs/{org}/actions/runners/generate-jitconfig
9//! body { name, runner_group_id, labels, work_folder }
10//! -> 201 { runner { … }, encoded_jit_config }
11//! ```
12//!
13//! Two scopes, one request shape, one response shape, and — after D4 — one
14//! credential. The Actions-service credential chain and message protocol this
15//! replaces were disproved by `d17-user-to-server-scale-set-chain.md`; what is
16//! here instead is documented, stable REST against a host and a token that
17//! already exist.
18//!
19//! # What the live spikes settled, and what each one costs to get wrong
20//!
21//! `v1` (`docs/spikes/d18-org-jit-verification.md`) drove both scopes against
22//! live GitHub. Four of its findings are load-bearing here rather than
23//! interesting:
24//!
25//! 1. **`runner_group_id` is mandatory.** Omitting it answers `422 Invalid
26//! input: object is missing required key: runner_group_id`. There is no
27//! server-side default, so [`JitRunnerRequest`] takes it as a required `u64`
28//! rather than an `Option` — a field that cannot be omitted cannot be
29//! forgotten.
30//! 2. **An unusable group answers `403` *or* `404`, depending on why.** Group
31//! `2` — the GitHub-hosted group — answered `403`; group `99999` answered
32//! `404`. Error handling keyed on `404` alone misreports the first case as "no
33//! such group" when the truth is "not yours to administer", so
34//! [`JitError::Forbidden`] and [`JitError::NotFound`] are separate outcomes
35//! and **both** name the runner group.
36//! 3. **`1` is not special.** A non-default group id (`3`) also returned `201`.
37//! Nothing here may hard-code `1`.
38//! 4. **No labels are added implicitly, and labels are stored lower-cased.** The
39//! `201` carries exactly the labels requested — no `self-hosted`, no OS, no
40//! architecture — so `runs-on: self-hosted` does **not** match a runner
41//! registered without that label. `b1`'s
42//! [`runner_manager_domain::policy::RoutingLabels::as_registration_labels`]
43//! is the array this module sends, and [`runner_manager_domain::model::Label`]
44//! lower-cases on construction, which is what keeps the labels this product
45//! asks for and the labels GitHub stores the same strings.
46//!
47//! # The encoded configuration is the one short-lived secret in the product
48//!
49//! `07-security.md`'s credential inventory lists exactly two sensitive values
50//! after D4: the persisted user access token, and this. It is returned in
51//! [`EncodedJitConfig`], whose `Debug` and `Display` redact, which does not
52//! implement [`serde::Serialize`], and which zeroises its buffer on drop.
53//!
54//! **This crate never writes it to disk and never puts it in an error message.**
55//! Every [`JitError`] is built from the *request* — target, runner group, name —
56//! and from GitHub's own `message`, never from a response body. The restrictive
57//! handoff to the runner process is `d1`'s primitive and `e3`'s job; the rule
58//! here is only that nothing leaves this module carrying the blob except
59//! [`JitRegistration`].
60//!
61//! ## What the wrapper does not cover, stated rather than implied
62//!
63//! [`crate::ApiResponse`] buffers the whole response body, so the encoded
64//! configuration also exists as bytes in that buffer until the response is
65//! dropped at the end of [`RestJit::generate_jit_config`]. That buffer is `c2`'s
66//! and is not zeroised. The intermediate `String` serde produces **is**
67//! zeroised here explicitly, immediately after the value is copied into the
68//! wrapper, because that one is this module's to scrub.
69//!
70//! The residual exposure is therefore one heap buffer, for the duration of one
71//! call, in a process that already holds the user access token. It is recorded
72//! rather than papered over: claiming the blob exists in exactly one place would
73//! be false, and a false claim is worse than a bounded one.
74//!
75//! # There is no job reservation, and this call is not one
76//!
77//! A JIT configuration registers a runner; it does **not** claim a job. The
78//! scale-set model's `AcquireJobs` has no REST equivalent, so another host may
79//! take the job this runner was started for
80//! (`01-current-architecture.md`, edge case 6). The runner then receives nothing
81//! and exits on its idle timeout — the surplus-runner path, which is an
82//! accepted, bounded cost with a test of its own (`h1` scenario 8).
83//!
84//! **Do not add a claim, a lease, or a local reservation table here to
85//! compensate**, and do not read this call as one.
86//! `demand::tests::nothing_in_this_crate_reserves_or_claims_a_job` makes that
87//! executable across the whole crate.
88
89use std::{
90 fmt,
91 sync::{
92 Arc,
93 atomic::{AtomicU64, Ordering},
94 },
95};
96
97use runner_manager_domain::{model::ScaleTarget, policy::RoutingLabels};
98use secrecy::{ExposeSecret, ExposeSecretMut, SecretString, zeroize::Zeroize};
99use serde::{Deserialize, Serialize};
100
101use crate::{
102 ApiRequest, AuthenticatedClient, GithubError,
103 rest::{CancelToken, InventoryError, RateLimited},
104};
105
106// ---------------------------------------------------------------------------
107// Constants
108// ---------------------------------------------------------------------------
109
110/// The runner's working directory, relative to its installation root.
111///
112/// `_work` is the GitHub runner's own default and the value `v1` registered
113/// with. It is a constant rather than an inline literal because `e3` creates the
114/// directory this names and `e2` lays the runner package out around it: three
115/// tasks agreeing on a path is a shared fact, not a repeated string.
116pub const DEFAULT_WORK_FOLDER: &str = "_work";
117
118/// The endpoint suffix both scopes share.
119pub const JITCONFIG_PATH: &str = "/actions/runners/generate-jitconfig";
120
121/// What GitHub answers a successful registration with.
122///
123/// Named because `201` rather than `200` is a fact about this endpoint that a
124/// reader should not have to re-derive, and because
125/// [`RestJit::generate_jit_config`] says so in a log line when the answer is
126/// anything else. It is **not** a gate: a different success status is reported
127/// and then decoded anyway, because the body is what this module needs and a
128/// `200` would carry the same one.
129pub const CREATED: u16 = 201;
130
131// ---------------------------------------------------------------------------
132// The request
133// ---------------------------------------------------------------------------
134
135/// One `generate-jitconfig` request, before a scope is chosen.
136///
137/// The same value registers at repository scope or organization scope: `v1`
138/// established that the two forms take the identical body and answer with the
139/// identical shape, so the scope is a [`ScaleTarget`] passed to
140/// [`JitGateway::generate_jit_config`] rather than a property of the request.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct JitRunnerRequest {
143 name: String,
144 runner_group_id: u64,
145 labels: Vec<String>,
146 work_folder: String,
147}
148
149impl JitRunnerRequest {
150 /// A registration for `name` in `runner_group_id`, carrying `labels`.
151 ///
152 /// `runner_group_id` is a required argument and not an `Option` on purpose:
153 /// `v1` proved there is no server-side default and that omitting the field
154 /// is a `422`, so the only way to send a request without one is not to be
155 /// able to build it.
156 ///
157 /// # An empty label set is not rejected here
158 ///
159 /// GitHub answers `labels: []` with `422 Invalid property /labels: 1 item
160 /// required; only 0 were supplied`, and that message is more useful to an
161 /// operator than anything this constructor could say — it names the property
162 /// and the requirement. The ordinary path cannot produce one anyway:
163 /// [`Self::for_policy`] takes a [`RoutingLabels`], which is non-empty by
164 /// construction because its host label has no removal path.
165 #[must_use]
166 pub fn new(
167 name: impl Into<String>,
168 runner_group_id: u64,
169 labels: impl IntoIterator<Item = impl Into<String>>,
170 ) -> Self {
171 Self {
172 name: name.into(),
173 runner_group_id,
174 labels: labels.into_iter().map(Into::into).collect(),
175 work_folder: DEFAULT_WORK_FOLDER.to_string(),
176 }
177 }
178
179 /// A registration carrying exactly the policy's routing labels.
180 ///
181 /// This is the constructor the product uses.
182 /// [`RoutingLabels::as_registration_labels`] is documented as "`c4` sends
183 /// exactly this", and exactly is the operative word: `v1` established that
184 /// **no labels are added implicitly**, so a label the operator expects to be
185 /// matchable has to be in this array or it does not exist on the runner.
186 #[must_use]
187 pub fn for_policy(
188 name: impl Into<String>,
189 runner_group_id: u64,
190 labels: &RoutingLabels,
191 ) -> Self {
192 Self::new(name, runner_group_id, labels.as_registration_labels())
193 }
194
195 /// Override the runner's working directory.
196 #[must_use]
197 pub fn with_work_folder(mut self, work_folder: impl Into<String>) -> Self {
198 self.work_folder = work_folder.into();
199 self
200 }
201
202 #[must_use]
203 pub fn name(&self) -> &str {
204 &self.name
205 }
206
207 #[must_use]
208 pub const fn runner_group_id(&self) -> u64 {
209 self.runner_group_id
210 }
211
212 #[must_use]
213 pub fn labels(&self) -> &[String] {
214 &self.labels
215 }
216
217 #[must_use]
218 pub fn work_folder(&self) -> &str {
219 &self.work_folder
220 }
221
222 fn body(&self) -> JitRequestBody<'_> {
223 JitRequestBody {
224 name: &self.name,
225 runner_group_id: self.runner_group_id,
226 labels: &self.labels,
227 work_folder: &self.work_folder,
228 }
229 }
230}
231
232/// The wire body, and nothing else.
233///
234/// A separate type from [`JitRunnerRequest`] so that the four documented keys
235/// are the whole of what is serialised. Adding an accessor, a builder field or a
236/// derived trait to the public type cannot change what goes on the wire, which
237/// is the property `the_request_body_is_exactly_the_four_documented_keys` pins.
238///
239/// No `skip_serializing_if` anywhere: `runner_group_id` is required, and an
240/// attribute that could ever omit it would reintroduce the one `422` `v1` went
241/// and measured.
242#[derive(Debug, Serialize)]
243struct JitRequestBody<'a> {
244 name: &'a str,
245 runner_group_id: u64,
246 labels: &'a [String],
247 work_folder: &'a str,
248}
249
250// ---------------------------------------------------------------------------
251// The secret
252// ---------------------------------------------------------------------------
253
254/// The encoded just-in-time configuration: a short-lived credential.
255///
256/// `07-security.md`, credential inventory: "Restrictive temporary handoff only.
257/// Delete immediately after launch; never persist." This type is what makes the
258/// first half enforceable at the type level rather than by everyone remembering:
259///
260/// * **`Debug` and `Display` redact.** Both are hand-written. A `#[derive(Debug)]`
261/// added later to a struct with a plain `String` field is precisely how this
262/// control is lost, which is why `lib.rs`'s crate documentation states the rule
263/// and why `tests/no_jit_config_reaches_the_logs.rs` plants that exact mistake
264/// as a positive control.
265/// * **It does not serialise.** There is no [`serde::Serialize`] impl, so it
266/// cannot be written into a config file, a SQLite row, a `status --json`
267/// payload or a structured log field by any code that compiles. The doctest
268/// below is the executable form of that claim.
269/// * **It zeroises on drop.** [`Drop`] calls `Self::scrub`, which zeroes the
270/// buffer through `zeroize`. `secrecy`'s [`SecretString`] also zeroises on its
271/// own drop; the explicit scrub is what makes the property *testable* rather
272/// than a statement about a dependency.
273/// * **It is not [`Clone`].** A clone of a secret is a second copy with its own
274/// lifetime, and this value's whole security property is a short one.
275///
276/// The error code is pinned, and that is the whole value of the doctest. A bare
277/// `compile_fail` passes when the snippet fails to compile for *any* reason — a
278/// typo, a renamed type, a missing import — so it would keep passing after
279/// someone added a `Serialize` derive and broke something else in the same
280/// edit. `E0277` is "the trait bound is not satisfied", which is the one reason
281/// this claim is about.
282///
283/// ```compile_fail,E0277
284/// # use runner_manager_github::jit::EncodedJitConfig;
285/// fn is_serialisable<T: serde::Serialize>(_: &T) {}
286/// let config = EncodedJitConfig::new("not-a-real-jit-configuration");
287/// // The JIT configuration must never reach a config file, a database row, a
288/// // `--json` payload or a structured log field. This must not compile.
289/// is_serialisable(&config);
290/// ```
291pub struct EncodedJitConfig(SecretString);
292
293impl EncodedJitConfig {
294 #[must_use]
295 pub fn new(raw: impl Into<String>) -> Self {
296 Self(SecretString::from(raw.into()))
297 }
298
299 /// The configuration itself, for the one caller that hands it to a runner
300 /// process.
301 ///
302 /// Named `expose` rather than `as_str` so that every use site says out loud
303 /// what it is doing, and so that `grep expose_jit` finds all of them.
304 #[must_use]
305 pub fn expose(&self) -> &str {
306 self.0.expose_secret()
307 }
308
309 /// Length in bytes, which is safe to log and useful for diagnosing a
310 /// truncated handoff. `v1` observed 4,088 characters at organization scope.
311 #[must_use]
312 pub fn len(&self) -> usize {
313 self.0.expose_secret().len()
314 }
315
316 #[must_use]
317 pub fn is_empty(&self) -> bool {
318 self.len() == 0
319 }
320
321 /// Overwrite the buffer with zeroes.
322 ///
323 /// Exactly what [`Drop`] calls, and `pub(crate)` rather than private so that
324 /// a test can invoke the *same* call and observe the result. Observing the
325 /// buffer after the drop itself is not possible without reading freed
326 /// memory, which is undefined behaviour; this is the strongest sound
327 /// alternative, and the gap it leaves — that `drop` calls `scrub` — is one
328 /// line directly below.
329 pub(crate) fn scrub(&mut self) {
330 self.0.expose_secret_mut().zeroize();
331 }
332}
333
334impl Drop for EncodedJitConfig {
335 fn drop(&mut self) {
336 self.scrub();
337 }
338}
339
340/// What `Debug` and `Display` render instead of the configuration.
341const REDACTED: &str = "[REDACTED JIT CONFIGURATION]";
342
343impl fmt::Debug for EncodedJitConfig {
344 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345 // The length is rendered and the value is not: a diagnostic that says
346 // "0 bytes" is what distinguishes a truncated handoff from a redacted
347 // one, and neither is the secret.
348 f.debug_tuple("EncodedJitConfig")
349 .field(&REDACTED)
350 .field(&format_args!("{} bytes", self.len()))
351 .finish()
352 }
353}
354
355impl fmt::Display for EncodedJitConfig {
356 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
357 f.write_str(REDACTED)
358 }
359}
360
361// ---------------------------------------------------------------------------
362// The response
363// ---------------------------------------------------------------------------
364
365/// The runner GitHub registered, as it described it in the `201`.
366///
367/// Distinct from [`crate::rest::Runner`], which is what the *inventory* endpoint
368/// reports, and deliberately so: this one carries `runner_group_id`, which the
369/// inventory shape has no field for and which is the value an operator needs
370/// when a later registration is refused for the group.
371#[derive(Debug, Clone, PartialEq, Eq)]
372pub struct JitRunner {
373 pub id: u64,
374 pub name: String,
375 pub os: String,
376 pub status: String,
377 pub busy: bool,
378 /// Optional in the wire schema. `v1` observed it present at both scopes; it
379 /// stays optional because a missing field is not a reason to fail a
380 /// registration that GitHub already accepted.
381 pub runner_group_id: Option<u64>,
382 /// The labels GitHub actually stored, **lower-cased** — see the module
383 /// documentation. Comparing these against what was requested is how a caller
384 /// learns that no labels were added implicitly.
385 pub labels: Vec<String>,
386}
387
388/// A registered runner and the configuration that starts it.
389///
390/// `Debug` is hand-written. The configuration's own `Debug` already redacts, so
391/// a derive would be safe *today*; it is written out because the field it is
392/// protecting is a secret and the crate's stated rule is that such types do not
393/// rely on a derive staying correct across an edit nobody reviews.
394pub struct JitRegistration {
395 config: EncodedJitConfig,
396 runner: JitRunner,
397}
398
399impl JitRegistration {
400 #[must_use]
401 pub fn new(config: EncodedJitConfig, runner: JitRunner) -> Self {
402 Self { config, runner }
403 }
404
405 #[must_use]
406 pub fn config(&self) -> &EncodedJitConfig {
407 &self.config
408 }
409
410 /// Take the configuration, leaving the runner reference behind.
411 ///
412 /// The handoff in `e3` wants the secret and the diagnostics separately, and
413 /// moving it out rather than cloning is what keeps there being one copy.
414 #[must_use]
415 pub fn into_config(self) -> EncodedJitConfig {
416 self.config
417 }
418
419 #[must_use]
420 pub fn runner(&self) -> &JitRunner {
421 &self.runner
422 }
423}
424
425impl fmt::Debug for JitRegistration {
426 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
427 f.debug_struct("JitRegistration")
428 .field("runner", &self.runner)
429 .field("config", &REDACTED)
430 .finish()
431 }
432}
433
434// ---------------------------------------------------------------------------
435// Failures
436// ---------------------------------------------------------------------------
437
438/// Everything a just-in-time registration can fail with.
439///
440/// The three GitHub answers `c4`'s specification names are **distinct outcomes,
441/// not one error**, because an operator's next action differs for each and a
442/// caller's does too: `403` is terminal and needs a permissions or runner-group
443/// change, `404` means the target or the group is not there, and `422` means the
444/// request itself was rejected.
445///
446/// # None of these carries the encoded configuration
447///
448/// Every variant is built from the request — target, runner group, name — and
449/// from GitHub's own `message` field. A failing response has no
450/// `encoded_jit_config` to leak, and a `201` that fails to *decode* is reported
451/// through [`GithubError::Decode`], which carries a `serde_json::Error` and not
452/// the body. `an_error_never_carries_the_encoded_configuration` pins it.
453#[derive(Debug, thiserror::Error)]
454pub enum JitError {
455 /// GitHub refused: the permission or the runner group does not allow it.
456 ///
457 /// **Terminal.** Nothing retries this, and nothing may: `d17` is the record
458 /// of what a `403` on this family of endpoints means and what it does not.
459 #[error(
460 "GitHub refused just-in-time runner registration for {target} in runner group \
461 {runner_group_id}{}. This is terminal — retrying will not change it. Check that the \
462 App installation grants `Administration: Read and write` for a repository target or \
463 `Self-hosted runners: Read and write` for an organization target, and that runner \
464 group {runner_group_id} is one this installation may administer; a GitHub-hosted \
465 runner group answers 403 and cannot be used",
466 message.as_deref().map(|m| format!(" ({m})")).unwrap_or_default()
467 )]
468 Forbidden {
469 target: String,
470 runner_group_id: u64,
471 message: Option<String>,
472 },
473
474 /// GitHub found neither the target nor the runner group.
475 ///
476 /// Separate from [`JitError::Forbidden`] because `v1` measured both answers
477 /// from the same mistake: a group that does not exist is `404`, a group that
478 /// exists but is not administrable is `403`. Collapsing them tells an
479 /// operator to create a group that is already there.
480 #[error(
481 "GitHub could not find the just-in-time registration target {target} or runner group \
482 {runner_group_id}{}. Check the target name, and that runner group \
483 {runner_group_id} exists — a group id that does not exist answers 404, while one \
484 that exists but cannot be administered answers 403",
485 message.as_deref().map(|m| format!(" ({m})")).unwrap_or_default()
486 )]
487 NotFound {
488 target: String,
489 runner_group_id: u64,
490 message: Option<String>,
491 },
492
493 /// GitHub rejected the request body: the name or the label set.
494 #[error(
495 "GitHub rejected the just-in-time runner registration for {target}{}. The runner name \
496 or the label set is not acceptable: `labels` must hold at least one item and \
497 `runner_group_id` is required",
498 message.as_deref().map(|m| format!(" ({m})")).unwrap_or_default()
499 )]
500 Rejected {
501 target: String,
502 message: Option<String>,
503 },
504
505 /// GitHub is rate limiting this credential. Resolves by waiting, and is the
506 /// one failure here that is not about the request.
507 #[error("{0}")]
508 RateLimited(RateLimited),
509
510 /// The caller withdrew the registration before it completed.
511 #[error("the just-in-time runner registration was cancelled before it completed")]
512 Cancelled,
513
514 #[error(transparent)]
515 Github(#[from] GithubError),
516}
517
518impl JitError {
519 /// Whether retrying this exact request could ever produce a different
520 /// answer.
521 ///
522 /// `403`, `404` and `422` are all `true` here, and a `403` **must** be:
523 /// `c4`'s specification says "a `403` must never become a retry loop", and
524 /// `d17` is the record of a design that spent a spike discovering what a
525 /// `403` on this family of endpoints means. A rejected credential is
526 /// terminal too — it resolves by an interactive `auth login`, not by
527 /// retrying.
528 ///
529 /// # Why an undecodable answer is terminal, and why it is the expensive one
530 ///
531 /// A body this client cannot parse will not parse on the next attempt, so
532 /// the answer to the question in the first line is plainly no. What makes
533 /// it worth spelling out is the cost of getting it wrong here rather than
534 /// anywhere else: `generate-jitconfig` answers `201` by **completing a
535 /// registration**, and the decode happens after that. A caller that read
536 /// this as retryable would issue a second registration, and a third, each
537 /// one a real runner created at GitHub whose one-shot configuration this
538 /// process then discards — a target silently accumulating registered
539 /// runners that never come online. So [`GithubError::Decode`] and
540 /// [`GithubError::Malformed`] are terminal, and
541 /// [`JitError::operator_action`] answers for both rather than leaving the
542 /// failure silent.
543 ///
544 /// # Why [`GithubError::Forbidden`] and a `404` under `Github` are not
545 ///
546 /// Both are reachable through the transparent `#[from]` without passing
547 /// `RestJit::classify`, and both would be terminal if they had. They stay
548 /// as they are because answering them here means keeping a second
549 /// status-code table beside `classify`'s, and the two would drift. For the
550 /// `403` it is worse than untidy: GitHub answers a secondary rate limit
551 /// with a `403`, `classify` runs [`RateLimited::detect`] *first* for
552 /// exactly that reason, and a predicate that called the raw variant
553 /// terminal without repeating that detection would turn the one 403 that
554 /// resolves by waiting into a permanent failure. An unclassified failure
555 /// reported as retryable costs a wasted request; an unclassified rate limit
556 /// reported as terminal costs the registration. The fix for those two is to
557 /// route them through `classify`, which every path inside this crate
558 /// already does.
559 #[must_use]
560 pub fn is_terminal(&self) -> bool {
561 match self {
562 Self::Forbidden { .. } | Self::NotFound { .. } | Self::Rejected { .. } => true,
563 // The rejected credential, and an answer this client cannot read.
564 // An authentication *lockout* is not terminal — it is the one 403
565 // that resolves by waiting — and a transport failure resolves when
566 // the network does.
567 Self::Github(error) => matches!(
568 error,
569 GithubError::AuthenticationFailed
570 | GithubError::Decode { .. }
571 | GithubError::Malformed { .. }
572 ),
573 Self::RateLimited(_) | Self::Cancelled => false,
574 }
575 }
576
577 /// The rate limit behind this failure, when there is one.
578 #[must_use]
579 pub fn rate_limited(&self) -> Option<&RateLimited> {
580 match self {
581 Self::RateLimited(limit) => Some(limit),
582 _ => None,
583 }
584 }
585
586 #[must_use]
587 pub fn is_cancelled(&self) -> bool {
588 matches!(self, Self::Cancelled)
589 }
590
591 /// What an operator can actually do about this, or `None` when there is
592 /// nothing for them to do.
593 ///
594 /// Every terminal outcome has one, which is what "terminal and
595 /// operator-actionable" means: a failure a human cannot act on and a
596 /// program will not retry is a dead end. `None` is correct for a rate limit
597 /// and a cancellation — both resolve without anyone doing anything.
598 #[must_use]
599 pub fn operator_action(&self) -> Option<String> {
600 match self {
601 Self::Forbidden {
602 target,
603 runner_group_id,
604 ..
605 } => Some(format!(
606 "Grant the App installation `Administration: Read and write` on {target} (or \
607 `Self-hosted runners: Read and write` for an organization), and use a runner \
608 group this installation may administer — runner group {runner_group_id} \
609 answered 403, which a GitHub-hosted group always does."
610 )),
611 Self::NotFound {
612 target,
613 runner_group_id,
614 ..
615 } => Some(format!(
616 "Check that {target} is spelled correctly and still exists, and that runner \
617 group {runner_group_id} exists in it."
618 )),
619 Self::Rejected { target, .. } => Some(format!(
620 "Correct the runner name or the routing labels for {target}: the label set \
621 must hold at least one label."
622 )),
623 Self::Github(GithubError::AuthenticationFailed) => {
624 Some("Run `runner-manager auth login` to sign in again.".to_string())
625 }
626 // Deliberately says nothing about the body it could not read. The
627 // undecodable answer is a `201`, so the body it is holding is a real
628 // encoded configuration, and this string is rendered wherever the
629 // error is; the module's redaction rule binds the remedy as tightly
630 // as the failure.
631 Self::Github(GithubError::Decode { .. } | GithubError::Malformed { .. }) => Some(
632 "Do not retry this registration: GitHub's answer could not be read, and \
633 `generate-jitconfig` answers 201 by creating the runner — so each further \
634 attempt can leave another registered runner that never comes online. \
635 Check the target's self-hosted runner list for offline runners matching \
636 this name and remove them, then report the response shape: this means \
637 GitHub's payload changed or the request body could not be built."
638 .to_string(),
639 ),
640 _ => None,
641 }
642 }
643}
644
645// ---------------------------------------------------------------------------
646// The gateway
647// ---------------------------------------------------------------------------
648
649/// Just-in-time runner registration.
650///
651/// A trait for [`crate::rest::InventoryGateway`]'s reason: `e3`'s launch path is
652/// tested against `runner_manager_testkit::github::FakeGithub`, with no network
653/// and no `wiremock` in its dependency graph. [`RestJit`] is the one
654/// implementation that talks to GitHub.
655#[async_trait::async_trait]
656pub trait JitGateway: fmt::Debug + Send + Sync {
657 /// Register one ephemeral runner and return its configuration.
658 ///
659 /// # Errors
660 /// Every variant of [`JitError`].
661 async fn generate_jit_config(
662 &self,
663 target: &ScaleTarget,
664 request: &JitRunnerRequest,
665 cancel: &CancelToken,
666 ) -> Result<JitRegistration, JitError>;
667}
668
669/// [`JitGateway`] over `api.github.com`.
670///
671/// Holds no credential of its own: authentication is entirely
672/// [`AuthenticatedClient`]'s, and this type only ever hands it an
673/// [`ApiRequest`] — whose `Debug` renders the body as `[REDACTED JSON]`, which
674/// matters here because this is the one place in the crate that posts one.
675pub struct RestJit {
676 client: Arc<AuthenticatedClient>,
677 requests_issued: AtomicU64,
678}
679
680impl fmt::Debug for RestJit {
681 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
682 f.debug_struct("RestJit")
683 .field(
684 "requests_issued",
685 &self.requests_issued.load(Ordering::Relaxed),
686 )
687 .finish_non_exhaustive()
688 }
689}
690
691impl RestJit {
692 #[must_use]
693 pub fn new(client: Arc<AuthenticatedClient>) -> Self {
694 Self {
695 client,
696 requests_issued: AtomicU64::new(0),
697 }
698 }
699
700 /// How many HTTP requests this gateway has issued.
701 ///
702 /// This is how "no code path retries a `403`" is asserted rather than
703 /// asserted about: one refused registration must leave this at one.
704 #[must_use]
705 pub fn requests_issued(&self) -> u64 {
706 self.requests_issued.load(Ordering::SeqCst)
707 }
708
709 /// The `generate-jitconfig` path for either scope.
710 ///
711 /// One function rather than a branch at each call site, because `v1`'s whole
712 /// organization finding is that the two differ in nothing but this string.
713 #[must_use]
714 pub fn path(target: &ScaleTarget) -> String {
715 match target {
716 ScaleTarget::Repository(repo) => {
717 format!("/repos/{}/{}{JITCONFIG_PATH}", repo.owner(), repo.repo())
718 }
719 ScaleTarget::Organization(org) => {
720 format!("/orgs/{}{JITCONFIG_PATH}", org.as_str())
721 }
722 }
723 }
724
725 /// Map a failure onto the outcome a caller branches on.
726 ///
727 /// Order matters. [`RateLimited::detect`] runs first because GitHub answers
728 /// a secondary rate limit with a `403`, and reporting that as a permissions
729 /// refusal would tell an operator to change a permission that is already
730 /// correct. `c3` owns that decision procedure and this consumes it rather
731 /// than writing a second one.
732 fn classify(error: GithubError, target: &ScaleTarget, runner_group_id: u64) -> JitError {
733 if let Some(limit) = RateLimited::detect(&error) {
734 return JitError::RateLimited(limit);
735 }
736 let target = target.slug();
737 match &error {
738 GithubError::Forbidden { message, .. } => JitError::Forbidden {
739 target,
740 runner_group_id,
741 message: message.clone(),
742 },
743 GithubError::Status {
744 status: 404,
745 message,
746 ..
747 } => JitError::NotFound {
748 target,
749 runner_group_id,
750 message: message.clone(),
751 },
752 GithubError::Status {
753 status: 422,
754 message,
755 ..
756 } => JitError::Rejected {
757 target,
758 message: message.clone(),
759 },
760 _ => JitError::Github(error),
761 }
762 }
763
764 fn from_inventory(
765 error: InventoryError,
766 target: &ScaleTarget,
767 runner_group_id: u64,
768 ) -> JitError {
769 match error {
770 InventoryError::Cancelled => JitError::Cancelled,
771 InventoryError::RateLimited(limit) => JitError::RateLimited(limit),
772 InventoryError::Github(error) => Self::classify(error, target, runner_group_id),
773 }
774 }
775}
776
777#[async_trait::async_trait]
778impl JitGateway for RestJit {
779 async fn generate_jit_config(
780 &self,
781 target: &ScaleTarget,
782 request: &JitRunnerRequest,
783 cancel: &CancelToken,
784 ) -> Result<JitRegistration, JitError> {
785 let group = request.runner_group_id();
786 let api_request = ApiRequest::post_json(Self::path(target), &request.body())
787 .map_err(|error| Self::classify(error, target, group))?;
788
789 // `CancelToken` is `c3`'s, and reusing it rather than inventing a second
790 // cancellation type is what lets `e1` hold one token across a refresh
791 // and the registration it decides on.
792 let response = cancel
793 .run(async {
794 // Counted inside the future for `c3`'s reason: `run`'s biased
795 // `select!` answers `Cancelled` without polling this block when
796 // the token is already flipped, so no socket is opened and the
797 // count stays a count of requests actually attempted.
798 self.requests_issued.fetch_add(1, Ordering::SeqCst);
799 self.client
800 .send(&api_request)
801 .await
802 .map_err(InventoryError::from)
803 })
804 .await
805 .map_err(|error| Self::from_inventory(error, target, group))?;
806
807 // A `warn!` and not a `debug_assert!`, and the difference is deliberate.
808 // `c3`'s `total_count` tripwire asserts because the number it guards is
809 // read *off* the field it doubts; this status is guarding nothing — the
810 // body is what matters, and a `200` would decode identically. An assert
811 // here would panic a debug build, and so kill a developer's agent, over
812 // a status code that changed nothing. The unexpected status is still
813 // worth saying out loud, because it would mean GitHub or
814 // `AuthenticatedClient::send` changed underneath this module.
815 if response.status().as_u16() != CREATED {
816 tracing::warn!(
817 status = response.status().as_u16(),
818 expected = CREATED,
819 "`generate-jitconfig` answered a success status other than 201; the \
820 registration is still decoded, but this endpoint has always answered 201"
821 );
822 }
823
824 let decoded: JitResponse = response.json().map_err(JitError::Github)?;
825 // Copied into the wrapper, then the intermediate scrubbed. serde owns
826 // this `String`, so it is the one copy of the secret this module can
827 // actually reach; the response buffer behind it is `c2`'s and is
828 // documented as the residual exposure at the top of this file.
829 let mut raw = decoded.encoded_jit_config;
830 let config = EncodedJitConfig::new(raw.as_str());
831 raw.zeroize();
832
833 tracing::debug!(
834 target = %target,
835 runner_id = decoded.runner.id,
836 runner_name = %decoded.runner.name,
837 runner_group_id = decoded.runner.runner_group_id,
838 config_bytes = config.len(),
839 "registered a just-in-time runner"
840 );
841
842 Ok(JitRegistration::new(
843 config,
844 JitRunner {
845 id: decoded.runner.id,
846 name: decoded.runner.name,
847 os: decoded.runner.os,
848 status: decoded.runner.status,
849 busy: decoded.runner.busy,
850 runner_group_id: decoded.runner.runner_group_id,
851 labels: decoded
852 .runner
853 .labels
854 .into_iter()
855 .map(|label| label.name)
856 .collect(),
857 },
858 ))
859 }
860}
861
862// ---------------------------------------------------------------------------
863// Wire shapes
864// ---------------------------------------------------------------------------
865
866/// The `201` body. `v1`: "top-level keys: `runner`, `encoded_jit_config` —
867/// exactly two", and "the response shape is **identical to the repository
868/// form**", which is why one type serves both scopes.
869#[derive(Debug, Deserialize)]
870struct JitResponse {
871 encoded_jit_config: String,
872 runner: RawJitRunner,
873}
874
875#[derive(Debug, Deserialize)]
876struct RawJitRunner {
877 id: u64,
878 #[serde(default)]
879 name: String,
880 #[serde(default)]
881 os: String,
882 #[serde(default)]
883 status: String,
884 #[serde(default)]
885 busy: bool,
886 runner_group_id: Option<u64>,
887 #[serde(default)]
888 labels: Vec<RawJitLabel>,
889}
890
891#[derive(Debug, Deserialize)]
892struct RawJitLabel {
893 name: String,
894}
895
896// Inline for the reason `rest.rs` records: `lib.rs`'s
897// `the_confidential_credential_scan_covers_every_source_file` requires every
898// `.rs` file under `src/` to appear in a list `c2` owns, so a second file here
899// could only be added by editing another task's file.
900#[cfg(test)]
901mod tests {
902 use super::*;
903 use crate::testing::{FIXTURE_TOKEN, TestClock};
904 use crate::{Endpoints, UserAccessToken};
905 use runner_manager_domain::model::{Arch, HostLabel, Os};
906 use serde_json::{Value, json};
907 use wiremock::{
908 Mock, MockServer, ResponseTemplate,
909 matchers::{body_json, method, path},
910 };
911
912 /// Shaped like a real encoded configuration — base64url of a JSON envelope —
913 /// and unmistakably not one. Long enough that a truncating leak still
914 /// contains a recognisable prefix.
915 const FIXTURE_JIT_CONFIG: &str = concat!(
916 "eyJmaXh0dXJlIjoibm90LWEtcmVhbC1qaXQtY29uZmlndXJhdGlvbiIsIm5vdGUiOiJpZi",
917 "B0aGlzIHN0cmluZyBhcHBlYXJzIGluIGEgbG9nIHRoZSByZWRhY3Rpb24gZmFpbGVkIn0"
918 );
919
920 fn repo_target() -> ScaleTarget {
921 ScaleTarget::repository("octo/dashboard").expect("a valid owner/repo")
922 }
923
924 fn org_target() -> ScaleTarget {
925 ScaleTarget::organization("octo-org").expect("a valid organization login")
926 }
927
928 /// Both scopes, so that every test written over this list runs against each
929 /// one. `v1`'s finding is that the two differ in nothing but the path, and
930 /// a list is how that stops being a claim.
931 fn both_scopes() -> Vec<ScaleTarget> {
932 vec![repo_target(), org_target()]
933 }
934
935 fn gateway(server: &MockServer) -> RestJit {
936 let client = AuthenticatedClient::new(
937 Endpoints::for_test_server(&server.uri()).expect("a valid test base"),
938 UserAccessToken::new(SecretString::from(FIXTURE_TOKEN)),
939 Arc::new(TestClock::default()),
940 )
941 .expect("the HTTP client builds");
942 RestJit::new(Arc::new(client))
943 }
944
945 fn request() -> JitRunnerRequest {
946 JitRunnerRequest::new(
947 "rm-home-win-x64-0001",
948 3,
949 ["rm-home-win-x64", "self-hosted"],
950 )
951 }
952
953 /// The `201` body, in the shape `v1` read back from live GitHub.
954 fn created_body(labels: &[&str]) -> Value {
955 json!({
956 "runner": {
957 "id": 73,
958 "name": "rm-home-win-x64-0001",
959 "os": "windows",
960 "status": "offline",
961 "busy": false,
962 "runner_group_id": 3,
963 "labels": labels
964 .iter()
965 .map(|name| json!({ "id": 1, "name": name, "type": "read-only" }))
966 .collect::<Vec<_>>()
967 },
968 "encoded_jit_config": FIXTURE_JIT_CONFIG
969 })
970 }
971
972 async fn mount_created(server: &MockServer, target: &ScaleTarget, body: Value) {
973 Mock::given(method("POST"))
974 .and(path(RestJit::path(target)))
975 .respond_with(ResponseTemplate::new(201).set_body_json(body))
976 .mount(server)
977 .await;
978 }
979
980 async fn mount_failure(server: &MockServer, target: &ScaleTarget, status: u16, message: &str) {
981 Mock::given(method("POST"))
982 .and(path(RestJit::path(target)))
983 .respond_with(
984 ResponseTemplate::new(status).set_body_json(json!({ "message": message })),
985 )
986 .mount(server)
987 .await;
988 }
989
990 // -- the happy path, at both scopes under one body ----------------------
991
992 /// The documented body shape goes out and the `201` comes back decoded — at
993 /// repository scope and at organization scope, under one shared test body.
994 #[tokio::test]
995 async fn a_201_decodes_into_the_configuration_and_the_runner_at_either_scope() {
996 for target in both_scopes() {
997 let server = MockServer::start().await;
998 // `body_json` is an *exact* match on the whole object, so an extra
999 // key, a missing key or a renamed key fails here rather than
1000 // silently reaching GitHub. This is the pin for "sends exactly the
1001 // documented body shape".
1002 Mock::given(method("POST"))
1003 .and(path(RestJit::path(&target)))
1004 .and(body_json(json!({
1005 "name": "rm-home-win-x64-0001",
1006 "runner_group_id": 3,
1007 "labels": ["rm-home-win-x64", "self-hosted"],
1008 "work_folder": "_work"
1009 })))
1010 .respond_with(
1011 ResponseTemplate::new(201)
1012 .set_body_json(created_body(&["rm-home-win-x64", "self-hosted"])),
1013 )
1014 .mount(&server)
1015 .await;
1016
1017 let gateway = gateway(&server);
1018 let registration = gateway
1019 .generate_jit_config(&target, &request(), &CancelToken::new())
1020 .await
1021 .unwrap_or_else(|error| panic!("a 201 at {target}: {error}"));
1022
1023 assert_eq!(
1024 registration.config().expose(),
1025 FIXTURE_JIT_CONFIG,
1026 "the encoded configuration must survive the round trip at {target}"
1027 );
1028 assert_eq!(registration.runner().id, 73);
1029 assert_eq!(registration.runner().name, "rm-home-win-x64-0001");
1030 assert_eq!(
1031 registration.runner().runner_group_id,
1032 Some(3),
1033 "the runner reference carries the group it was registered in, which \
1034 `c3`'s inventory shape has no field for"
1035 );
1036 assert_eq!(
1037 registration.runner().labels,
1038 vec!["rm-home-win-x64".to_string(), "self-hosted".to_string()],
1039 "no labels are added implicitly, so the 201 carries exactly what was sent"
1040 );
1041 assert_eq!(gateway.requests_issued(), 1);
1042 }
1043 }
1044
1045 /// An unexpected success status is reported, not fatal.
1046 ///
1047 /// The status guards nothing here — the body is what this module needs, and
1048 /// a `200` carries the same one. An assertion would panic a debug build, and
1049 /// so kill a developer's agent, over a code that changed nothing; this pins
1050 /// that the registration still succeeds.
1051 #[tokio::test]
1052 async fn a_success_status_other_than_201_is_still_decoded() {
1053 let server = MockServer::start().await;
1054 Mock::given(method("POST"))
1055 .and(path(RestJit::path(&repo_target())))
1056 .respond_with(ResponseTemplate::new(200).set_body_json(created_body(&["a"])))
1057 .mount(&server)
1058 .await;
1059
1060 let registration = gateway(&server)
1061 .generate_jit_config(&repo_target(), &request(), &CancelToken::new())
1062 .await
1063 .expect("a 200 carries the same body a 201 does and must not be fatal");
1064 assert_eq!(registration.config().expose(), FIXTURE_JIT_CONFIG);
1065 assert_ne!(
1066 200, CREATED,
1067 "the fixture has to be a status the code notices, or this proves nothing"
1068 );
1069 }
1070
1071 /// The path differs between scopes and nothing else does.
1072 #[test]
1073 fn the_two_scopes_differ_only_in_the_path() {
1074 assert_eq!(
1075 RestJit::path(&repo_target()),
1076 "/repos/octo/dashboard/actions/runners/generate-jitconfig"
1077 );
1078 assert_eq!(
1079 RestJit::path(&org_target()),
1080 "/orgs/octo-org/actions/runners/generate-jitconfig"
1081 );
1082 assert!(
1083 RestJit::path(&repo_target()).ends_with(JITCONFIG_PATH)
1084 && RestJit::path(&org_target()).ends_with(JITCONFIG_PATH),
1085 "one suffix, two prefixes -- that is the whole of `v1`'s organization finding"
1086 );
1087 }
1088
1089 /// `runner_group_id` is always on the wire, because it cannot be omitted.
1090 ///
1091 /// `v1` measured the alternative: omitting the key answers `422 Invalid
1092 /// input: object is missing required key: runner_group_id`. There is no
1093 /// server-side default, so the field is a required constructor argument and
1094 /// the serialised body carries no attribute that could ever drop it.
1095 #[test]
1096 fn the_request_body_is_exactly_the_four_documented_keys() {
1097 let body = serde_json::to_value(request().body()).expect("the body serialises");
1098 let object = body.as_object().expect("a JSON object");
1099
1100 let mut keys: Vec<&str> = object.keys().map(String::as_str).collect();
1101 keys.sort_unstable();
1102 assert_eq!(
1103 keys,
1104 vec!["labels", "name", "runner_group_id", "work_folder"],
1105 "`04-subsystem-contracts.md` types this body as {{name, runner_group_id, \
1106 labels, work_folder}}; an extra key is an untested request and a missing \
1107 `runner_group_id` is a 422"
1108 );
1109 assert_eq!(object["runner_group_id"], json!(3));
1110 assert_eq!(object["work_folder"], json!("_work"));
1111
1112 // `1` is not special: `v1` registered successfully in group 3.
1113 let other = JitRunnerRequest::new("n", 99, ["a"]);
1114 assert_eq!(
1115 serde_json::to_value(other.body()).expect("serialises")["runner_group_id"],
1116 json!(99),
1117 "any administrable group id works, so nothing here may assume 1"
1118 );
1119 }
1120
1121 /// The labels sent are the policy's, verbatim and lower-cased, with nothing
1122 /// added.
1123 #[test]
1124 fn a_policy_registers_exactly_its_own_routing_labels() {
1125 let labels = RoutingLabels::derive(
1126 &HostLabel::new("home").expect("a valid host label"),
1127 Os::Windows,
1128 Arch::X64,
1129 );
1130 let request = JitRunnerRequest::for_policy("runner-1", 1, &labels);
1131
1132 assert_eq!(request.labels(), &["rm-home-win-x64".to_string()]);
1133 assert!(
1134 !request.labels().iter().any(|label| label == "self-hosted"),
1135 "`v1` established that no labels are added implicitly; adding one here would \
1136 make a runner answer a `runs-on` the operator never asked it to"
1137 );
1138 assert!(
1139 request
1140 .labels()
1141 .iter()
1142 .all(|l| l == &l.to_ascii_lowercase()),
1143 "GitHub stores labels lower-cased, so what is sent and what is stored must be \
1144 the same string"
1145 );
1146 }
1147
1148 // -- the three failure modes -------------------------------------------
1149
1150 /// `403`, `404` and `422` are three outcomes, not one — and none of them is
1151 /// retried.
1152 #[tokio::test]
1153 async fn each_failure_status_is_a_distinct_outcome_and_none_is_retried() {
1154 for target in both_scopes() {
1155 // 403: the permission or the group does not allow it.
1156 let server = MockServer::start().await;
1157 mount_failure(
1158 &server,
1159 &target,
1160 403,
1161 "GitHub hosted runner groups cannot be modified",
1162 )
1163 .await;
1164 let refused = gateway(&server);
1165 let error = refused
1166 .generate_jit_config(&target, &request(), &CancelToken::new())
1167 .await
1168 .expect_err("a 403 is a failure");
1169 assert!(
1170 matches!(
1171 error,
1172 JitError::Forbidden {
1173 runner_group_id: 3,
1174 ..
1175 }
1176 ),
1177 "a 403 must be its own outcome and must name the group: {error:?}"
1178 );
1179 assert!(error.is_terminal(), "a 403 is terminal");
1180 assert!(
1181 error.operator_action().is_some(),
1182 "terminal and operator-actionable: a failure nobody can act on and nothing \
1183 retries is a dead end"
1184 );
1185 assert_eq!(
1186 refused.requests_issued(),
1187 1,
1188 "no code path may retry a 403; `d17` is the record of what it means"
1189 );
1190
1191 // 404: the target or the group is not there.
1192 let server = MockServer::start().await;
1193 mount_failure(&server, &target, 404, "Not Found").await;
1194 let missing = gateway(&server);
1195 let error = missing
1196 .generate_jit_config(&target, &request(), &CancelToken::new())
1197 .await
1198 .expect_err("a 404 is a failure");
1199 assert!(
1200 matches!(
1201 error,
1202 JitError::NotFound {
1203 runner_group_id: 3,
1204 ..
1205 }
1206 ),
1207 "a 404 must be its own outcome: {error:?}"
1208 );
1209 assert!(error.is_terminal());
1210 assert_eq!(missing.requests_issued(), 1);
1211
1212 // 422: the body was rejected.
1213 let server = MockServer::start().await;
1214 mount_failure(
1215 &server,
1216 &target,
1217 422,
1218 "Invalid property /labels: 1 item required; only 0 were supplied",
1219 )
1220 .await;
1221 let rejected = gateway(&server);
1222 let error = rejected
1223 .generate_jit_config(&target, &request(), &CancelToken::new())
1224 .await
1225 .expect_err("a 422 is a failure");
1226 assert!(
1227 matches!(error, JitError::Rejected { .. }),
1228 "a 422 must be its own outcome: {error:?}"
1229 );
1230 assert!(error.is_terminal());
1231 assert_eq!(rejected.requests_issued(), 1);
1232 }
1233 }
1234
1235 /// An unusable runner group answers `403` **or** `404`, and the two must not
1236 /// be collapsed.
1237 ///
1238 /// `v1` measured both from the same operator mistake — a wrong
1239 /// `runner_group_id`. Group `2`, the GitHub-hosted group, answered `403`;
1240 /// group `99999` answered `404`. Error handling keyed on `404` alone tells
1241 /// an operator to create a group that already exists.
1242 #[tokio::test]
1243 async fn an_unusable_runner_group_is_reported_differently_for_403_and_404() {
1244 let target = org_target();
1245
1246 let server = MockServer::start().await;
1247 mount_failure(
1248 &server,
1249 &target,
1250 403,
1251 "GitHub hosted runner groups cannot be modified",
1252 )
1253 .await;
1254 let hosted_group = gateway(&server)
1255 .generate_jit_config(
1256 &target,
1257 &JitRunnerRequest::new("n", 2, ["a"]),
1258 &CancelToken::new(),
1259 )
1260 .await
1261 .expect_err("group 2 is not administrable");
1262
1263 let server = MockServer::start().await;
1264 mount_failure(&server, &target, 404, "Not Found").await;
1265 let missing_group = gateway(&server)
1266 .generate_jit_config(
1267 &target,
1268 &JitRunnerRequest::new("n", 99_999, ["a"]),
1269 &CancelToken::new(),
1270 )
1271 .await
1272 .expect_err("group 99999 does not exist");
1273
1274 assert!(matches!(
1275 hosted_group,
1276 JitError::Forbidden {
1277 runner_group_id: 2,
1278 ..
1279 }
1280 ));
1281 assert!(matches!(
1282 missing_group,
1283 JitError::NotFound {
1284 runner_group_id: 99_999,
1285 ..
1286 }
1287 ));
1288 assert_ne!(
1289 hosted_group.operator_action(),
1290 missing_group.operator_action(),
1291 "the two answers need different remedies: one is a permission on an existing \
1292 group, the other is a group that is not there"
1293 );
1294 assert!(
1295 hosted_group.to_string().contains("403"),
1296 "the 403 message must explain that a GitHub-hosted group always answers this"
1297 );
1298 assert!(
1299 missing_group.to_string().contains("404"),
1300 "and the 404 message must explain the difference in the other direction"
1301 );
1302 }
1303
1304 /// A secondary rate limit arrives as a `403`, and must not be reported as a
1305 /// permissions refusal.
1306 ///
1307 /// A rate limit resolves by waiting; a permissions refusal does not resolve
1308 /// at all. Reporting the first as the second tells an operator to change a
1309 /// permission that is already correct, and — worse here — marks a transient
1310 /// failure terminal, so the runner is never registered.
1311 #[tokio::test]
1312 async fn a_rate_limit_wearing_a_403_is_not_a_permissions_refusal() {
1313 let server = MockServer::start().await;
1314 Mock::given(method("POST"))
1315 .and(path(RestJit::path(&repo_target())))
1316 .respond_with(
1317 ResponseTemplate::new(403)
1318 .insert_header("retry-after", "60")
1319 .set_body_json(json!({
1320 "message": "You have exceeded a secondary rate limit"
1321 })),
1322 )
1323 .mount(&server)
1324 .await;
1325
1326 let error = gateway(&server)
1327 .generate_jit_config(&repo_target(), &request(), &CancelToken::new())
1328 .await
1329 .expect_err("a rate limit is a failure");
1330
1331 assert!(
1332 error.rate_limited().is_some(),
1333 "`c3`'s detector owns this decision and it said rate limit: {error:?}"
1334 );
1335 assert!(
1336 !error.is_terminal(),
1337 "a rate limit resolves by waiting; marking it terminal never registers the runner"
1338 );
1339 assert!(error.operator_action().is_none());
1340 }
1341
1342 /// A cancelled registration opens no socket at all.
1343 #[tokio::test]
1344 async fn a_cancelled_registration_issues_no_request() {
1345 let server = MockServer::start().await;
1346 mount_created(&server, &repo_target(), created_body(&["a"])).await;
1347 let gateway = gateway(&server);
1348 let cancel = CancelToken::new();
1349 cancel.cancel();
1350
1351 let error = gateway
1352 .generate_jit_config(&repo_target(), &request(), &cancel)
1353 .await
1354 .expect_err("a cancelled token withdraws the registration");
1355 assert!(error.is_cancelled());
1356 assert_eq!(
1357 gateway.requests_issued(),
1358 0,
1359 "the count is of requests actually attempted, and a withdrawn one is not"
1360 );
1361 }
1362
1363 // -- the secret ---------------------------------------------------------
1364
1365 /// The configuration is absent from `Debug` and from `Display`.
1366 #[test]
1367 fn the_configuration_is_absent_from_debug_and_display() {
1368 let config = EncodedJitConfig::new(FIXTURE_JIT_CONFIG);
1369
1370 let debug = format!("{config:?}");
1371 let display = format!("{config}");
1372 assert!(
1373 !debug.contains(FIXTURE_JIT_CONFIG),
1374 "Debug leaked it: {debug}"
1375 );
1376 assert!(
1377 !display.contains(FIXTURE_JIT_CONFIG),
1378 "Display leaked it: {display}"
1379 );
1380 assert!(debug.contains(REDACTED) && display.contains(REDACTED));
1381 assert!(
1382 debug.contains(&format!("{} bytes", FIXTURE_JIT_CONFIG.len())),
1383 "the length is useful and is not the secret: {debug}"
1384 );
1385
1386 // And through the registration that carries it, which is what a caller
1387 // actually holds.
1388 let registration = JitRegistration::new(
1389 EncodedJitConfig::new(FIXTURE_JIT_CONFIG),
1390 JitRunner {
1391 id: 73,
1392 name: "runner".into(),
1393 os: "windows".into(),
1394 status: "offline".into(),
1395 busy: false,
1396 runner_group_id: Some(1),
1397 labels: vec!["rm-home-win-x64".into()],
1398 },
1399 );
1400 let rendered = format!("{registration:?}");
1401 assert!(
1402 !rendered.contains(FIXTURE_JIT_CONFIG),
1403 "the registration's Debug leaked it: {rendered}"
1404 );
1405 assert!(
1406 rendered.contains("runner"),
1407 "and still says something useful"
1408 );
1409 }
1410
1411 /// The scan above can actually see a leak.
1412 ///
1413 /// A redaction test that never had the secret in reach passes for the wrong
1414 /// reason. This plants the exact mistake `lib.rs`'s crate documentation
1415 /// names — a plain `String` field with a derived `Debug` — and requires the
1416 /// same assertions to catch it.
1417 #[test]
1418 fn the_redaction_assertions_would_catch_a_derived_debug_over_a_plain_string() {
1419 #[derive(Debug)]
1420 struct ConfigWithADerivedDebug {
1421 #[allow(dead_code)]
1422 encoded_jit_config: String,
1423 }
1424
1425 let leaky = ConfigWithADerivedDebug {
1426 encoded_jit_config: FIXTURE_JIT_CONFIG.to_string(),
1427 };
1428 assert!(
1429 format!("{leaky:?}").contains(FIXTURE_JIT_CONFIG),
1430 "the assertions above cannot see a plain-String secret rendered through a \
1431 derived Debug, so every one of them is worthless"
1432 );
1433 }
1434
1435 /// The wrapper's buffer is zeroised.
1436 ///
1437 /// `scrub` is exactly the call [`Drop::drop`] makes. Observing the buffer
1438 /// *after* the drop would mean reading freed memory, which is undefined
1439 /// behaviour and would be a test that proves nothing while appearing to
1440 /// prove everything; this invokes the same code on a live value instead.
1441 #[test]
1442 fn the_wrapper_scrubs_its_buffer() {
1443 let mut config = EncodedJitConfig::new(FIXTURE_JIT_CONFIG);
1444 assert_eq!(
1445 config.expose(),
1446 FIXTURE_JIT_CONFIG,
1447 "the fixture has to be really in there, or the assertion below is vacuous"
1448 );
1449
1450 config.scrub();
1451
1452 assert!(
1453 config.expose().bytes().all(|byte| byte == 0),
1454 "every byte of the buffer must be zero after a scrub, not merely unreachable"
1455 );
1456 assert!(!config.expose().contains("eyJ"));
1457 assert_eq!(
1458 config.len(),
1459 FIXTURE_JIT_CONFIG.len(),
1460 "`str::zeroize` overwrites in place rather than shortening, so the length is \
1461 unchanged and every byte of it is zero"
1462 );
1463 }
1464
1465 /// No error value carries the encoded configuration, on any path.
1466 #[tokio::test]
1467 async fn an_error_never_carries_the_encoded_configuration() {
1468 // A `201` whose body cannot be decoded is the one failure that has the
1469 // secret in reach: the response really does carry it.
1470 let server = MockServer::start().await;
1471 Mock::given(method("POST"))
1472 .and(path(RestJit::path(&repo_target())))
1473 .respond_with(ResponseTemplate::new(201).set_body_json(json!({
1474 "encoded_jit_config": FIXTURE_JIT_CONFIG,
1475 "runner": { "name": "no id field, so this cannot decode" }
1476 })))
1477 .mount(&server)
1478 .await;
1479
1480 let error = gateway(&server)
1481 .generate_jit_config(&repo_target(), &request(), &CancelToken::new())
1482 .await
1483 .expect_err("a 201 missing `runner.id` cannot decode");
1484
1485 let rendered = format!("{error} {error:?}");
1486 assert!(
1487 !rendered.contains(FIXTURE_JIT_CONFIG),
1488 "a decode failure must not carry the body it failed to decode: {rendered}"
1489 );
1490 assert!(
1491 !rendered.contains("eyJ"),
1492 "not even a prefix of it: {rendered}"
1493 );
1494
1495 // And the three failure statuses, each answered with a body that carries
1496 // an `encoded_jit_config` key it has no business carrying. GitHub does
1497 // not send one on a failure; the point is that a variant which ever
1498 // rendered a response *body* rather than its `message` would be caught
1499 // here rather than in production.
1500 for status in [403_u16, 404, 422] {
1501 let server = MockServer::start().await;
1502 Mock::given(method("POST"))
1503 .and(path(RestJit::path(&repo_target())))
1504 .respond_with(ResponseTemplate::new(status).set_body_json(json!({
1505 "message": "Resource not accessible by integration",
1506 "encoded_jit_config": FIXTURE_JIT_CONFIG
1507 })))
1508 .mount(&server)
1509 .await;
1510 let error = gateway(&server)
1511 .generate_jit_config(&repo_target(), &request(), &CancelToken::new())
1512 .await
1513 .expect_err("a failure status");
1514
1515 let rendered = format!("{error} {error:?}");
1516 assert!(
1517 !rendered.contains(FIXTURE_JIT_CONFIG),
1518 "a {status} rendered a response body verbatim: {rendered}"
1519 );
1520 // GitHub's own `message` is the opposite requirement, and both have
1521 // to hold at once: an error that redacted the message along with the
1522 // body would be safe and useless. `Resource not accessible by
1523 // integration` is the sentence that tells an operator which
1524 // permission is missing.
1525 assert!(
1526 rendered.contains("Resource not accessible by integration"),
1527 "GitHub's message is what makes a {status} operator-actionable and must \
1528 survive: {rendered}"
1529 );
1530 }
1531 }
1532
1533 /// A `201` this client cannot decode is terminal, and tells an operator what
1534 /// to do about it.
1535 ///
1536 /// This is the one failure on this path where retrying is not merely
1537 /// useless but actively destructive, and the two facts compound. A `201`
1538 /// **is a completed registration**: GitHub has already created the runner
1539 /// and spent the one-shot configuration on it. So a caller that reads
1540 /// [`JitError::is_terminal`] as "safe to try again" issues a second
1541 /// registration, and a third, each one a fresh runner that this process
1542 /// then throws away undecoded — a target quietly accumulating registered
1543 /// runners that never come online, none of which is visible from the
1544 /// failure itself.
1545 ///
1546 /// It was reported non-terminal, and with no
1547 /// [`JitError::operator_action`], so the loop was silent as well as
1548 /// unbounded. Both halves are pinned here: the answer to "could retrying
1549 /// this exact request ever produce a different answer" is no — a body this
1550 /// client cannot parse will not parse on the next attempt — and a terminal
1551 /// outcome without an operator action is the dead end that
1552 /// `operator_action`'s own documentation forbids.
1553 #[tokio::test]
1554 async fn an_undecodable_registration_is_terminal_and_operator_actionable() {
1555 let server = MockServer::start().await;
1556 Mock::given(method("POST"))
1557 .and(path(RestJit::path(&repo_target())))
1558 .respond_with(ResponseTemplate::new(201).set_body_json(json!({
1559 "encoded_jit_config": FIXTURE_JIT_CONFIG,
1560 "runner": { "name": "no id field, so this cannot decode" }
1561 })))
1562 .mount(&server)
1563 .await;
1564
1565 let error = gateway(&server)
1566 .generate_jit_config(&repo_target(), &request(), &CancelToken::new())
1567 .await
1568 .expect_err("a 201 missing `runner.id` cannot decode");
1569
1570 assert!(
1571 matches!(error, JitError::Github(GithubError::Decode { .. })),
1572 "the undecodable 201 arrives through the transparent `#[from]`, which is \
1573 what makes its terminality a question about `GithubError` rather than \
1574 about a `JitError` variant: {error:?}"
1575 );
1576 assert!(
1577 error.is_terminal(),
1578 "a response body this client cannot parse will not parse on a retry, and \
1579 every retry registers another runner that is then discarded"
1580 );
1581 let action = error
1582 .operator_action()
1583 .expect("a terminal outcome with no operator action is a dead end");
1584 assert!(
1585 action.contains("Do not retry"),
1586 "the action has to say the one thing a caller must not do: {action}"
1587 );
1588 assert!(
1589 !action.contains(FIXTURE_JIT_CONFIG) && !action.contains("eyJ"),
1590 "the operator action is rendered wherever the error is, so it is bound by \
1591 the same redaction rule as the error itself: {action}"
1592 );
1593
1594 // The sibling variant, which reaches the same conclusion by the same
1595 // route: `Malformed` is a value this client cannot use, and it will be
1596 // the same value next time. Constructed directly because there is no
1597 // response shape that produces it on the registration path today --
1598 // which is exactly why it needs pinning rather than leaving to chance.
1599 let malformed = JitError::Github(GithubError::Malformed {
1600 what: "runner.id",
1601 value: "not a number".to_string(),
1602 });
1603 assert!(malformed.is_terminal());
1604 assert!(malformed.operator_action().is_some());
1605
1606 // And the two failures that stay non-terminal, so the widening above is
1607 // read as deliberate rather than as "everything under `Github` is
1608 // terminal now". A lockout resolves by waiting and a transport failure
1609 // resolves when the network does; retrying either is the correct
1610 // behaviour, and neither has registered anything.
1611 for still_retryable in [
1612 JitError::Github(GithubError::AuthenticationLockout {
1613 retry_after: std::time::Duration::from_secs(60),
1614 }),
1615 JitError::Cancelled,
1616 ] {
1617 assert!(
1618 !still_retryable.is_terminal(),
1619 "{still_retryable:?} resolves on its own and must not be reported as \
1620 terminal"
1621 );
1622 }
1623 }
1624
1625 /// A registration whose response reports different labels than were
1626 /// requested is still returned, and says so.
1627 ///
1628 /// `v1` observed that label *order* is not preserved and that labels come
1629 /// back lower-cased, so an equality check on the array would fail on a
1630 /// correct registration. The runner reference carries what GitHub stored, and
1631 /// comparing is the caller's business.
1632 #[tokio::test]
1633 async fn the_runner_reference_reports_the_labels_github_actually_stored() {
1634 let server = MockServer::start().await;
1635 mount_created(
1636 &server,
1637 &repo_target(),
1638 created_body(&["self-hosted", "rm-home-win-x64"]),
1639 )
1640 .await;
1641
1642 let registration = gateway(&server)
1643 .generate_jit_config(&repo_target(), &request(), &CancelToken::new())
1644 .await
1645 .expect("a 201");
1646
1647 assert_eq!(
1648 registration.runner().labels,
1649 vec!["self-hosted".to_string(), "rm-home-win-x64".to_string()],
1650 "what GitHub stored, in the order GitHub returned it -- `v1` established that \
1651 the order is not the order requested"
1652 );
1653 }
1654}