Skip to main content

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}