use std::{
fmt,
sync::{
Arc,
atomic::{AtomicU64, Ordering},
},
};
use runner_manager_domain::{model::ScaleTarget, policy::RoutingLabels};
use secrecy::{ExposeSecret, ExposeSecretMut, SecretString, zeroize::Zeroize};
use serde::{Deserialize, Serialize};
use crate::{
ApiRequest, AuthenticatedClient, GithubError,
rest::{CancelToken, InventoryError, RateLimited},
};
pub const DEFAULT_WORK_FOLDER: &str = "_work";
pub const JITCONFIG_PATH: &str = "/actions/runners/generate-jitconfig";
pub const CREATED: u16 = 201;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JitRunnerRequest {
name: String,
runner_group_id: u64,
labels: Vec<String>,
work_folder: String,
}
impl JitRunnerRequest {
#[must_use]
pub fn new(
name: impl Into<String>,
runner_group_id: u64,
labels: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
Self {
name: name.into(),
runner_group_id,
labels: labels.into_iter().map(Into::into).collect(),
work_folder: DEFAULT_WORK_FOLDER.to_string(),
}
}
#[must_use]
pub fn for_policy(
name: impl Into<String>,
runner_group_id: u64,
labels: &RoutingLabels,
) -> Self {
Self::new(name, runner_group_id, labels.as_registration_labels())
}
#[must_use]
pub fn with_work_folder(mut self, work_folder: impl Into<String>) -> Self {
self.work_folder = work_folder.into();
self
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub const fn runner_group_id(&self) -> u64 {
self.runner_group_id
}
#[must_use]
pub fn labels(&self) -> &[String] {
&self.labels
}
#[must_use]
pub fn work_folder(&self) -> &str {
&self.work_folder
}
fn body(&self) -> JitRequestBody<'_> {
JitRequestBody {
name: &self.name,
runner_group_id: self.runner_group_id,
labels: &self.labels,
work_folder: &self.work_folder,
}
}
}
#[derive(Debug, Serialize)]
struct JitRequestBody<'a> {
name: &'a str,
runner_group_id: u64,
labels: &'a [String],
work_folder: &'a str,
}
pub struct EncodedJitConfig(SecretString);
impl EncodedJitConfig {
#[must_use]
pub fn new(raw: impl Into<String>) -> Self {
Self(SecretString::from(raw.into()))
}
#[must_use]
pub fn expose(&self) -> &str {
self.0.expose_secret()
}
#[must_use]
pub fn len(&self) -> usize {
self.0.expose_secret().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub(crate) fn scrub(&mut self) {
self.0.expose_secret_mut().zeroize();
}
}
impl Drop for EncodedJitConfig {
fn drop(&mut self) {
self.scrub();
}
}
const REDACTED: &str = "[REDACTED JIT CONFIGURATION]";
impl fmt::Debug for EncodedJitConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("EncodedJitConfig")
.field(&REDACTED)
.field(&format_args!("{} bytes", self.len()))
.finish()
}
}
impl fmt::Display for EncodedJitConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(REDACTED)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JitRunner {
pub id: u64,
pub name: String,
pub os: String,
pub status: String,
pub busy: bool,
pub runner_group_id: Option<u64>,
pub labels: Vec<String>,
}
pub struct JitRegistration {
config: EncodedJitConfig,
runner: JitRunner,
}
impl JitRegistration {
#[must_use]
pub fn new(config: EncodedJitConfig, runner: JitRunner) -> Self {
Self { config, runner }
}
#[must_use]
pub fn config(&self) -> &EncodedJitConfig {
&self.config
}
#[must_use]
pub fn into_config(self) -> EncodedJitConfig {
self.config
}
#[must_use]
pub fn runner(&self) -> &JitRunner {
&self.runner
}
}
impl fmt::Debug for JitRegistration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("JitRegistration")
.field("runner", &self.runner)
.field("config", &REDACTED)
.finish()
}
}
#[derive(Debug, thiserror::Error)]
pub enum JitError {
#[error(
"GitHub refused just-in-time runner registration for {target} in runner group \
{runner_group_id}{}. This is terminal — retrying will not change it. Check that the \
App installation grants `Administration: Read and write` for a repository target or \
`Self-hosted runners: Read and write` for an organization target, and that runner \
group {runner_group_id} is one this installation may administer; a GitHub-hosted \
runner group answers 403 and cannot be used",
message.as_deref().map(|m| format!(" ({m})")).unwrap_or_default()
)]
Forbidden {
target: String,
runner_group_id: u64,
message: Option<String>,
},
#[error(
"GitHub could not find the just-in-time registration target {target} or runner group \
{runner_group_id}{}. Check the target name, and that runner group \
{runner_group_id} exists — a group id that does not exist answers 404, while one \
that exists but cannot be administered answers 403",
message.as_deref().map(|m| format!(" ({m})")).unwrap_or_default()
)]
NotFound {
target: String,
runner_group_id: u64,
message: Option<String>,
},
#[error(
"GitHub rejected the just-in-time runner registration for {target}{}. The runner name \
or the label set is not acceptable: `labels` must hold at least one item and \
`runner_group_id` is required",
message.as_deref().map(|m| format!(" ({m})")).unwrap_or_default()
)]
Rejected {
target: String,
message: Option<String>,
},
#[error("{0}")]
RateLimited(RateLimited),
#[error("the just-in-time runner registration was cancelled before it completed")]
Cancelled,
#[error(transparent)]
Github(#[from] GithubError),
}
impl JitError {
#[must_use]
pub fn is_terminal(&self) -> bool {
match self {
Self::Forbidden { .. } | Self::NotFound { .. } | Self::Rejected { .. } => true,
Self::Github(error) => matches!(
error,
GithubError::AuthenticationFailed
| GithubError::Decode { .. }
| GithubError::Malformed { .. }
),
Self::RateLimited(_) | Self::Cancelled => false,
}
}
#[must_use]
pub fn rate_limited(&self) -> Option<&RateLimited> {
match self {
Self::RateLimited(limit) => Some(limit),
_ => None,
}
}
#[must_use]
pub fn is_cancelled(&self) -> bool {
matches!(self, Self::Cancelled)
}
#[must_use]
pub fn operator_action(&self) -> Option<String> {
match self {
Self::Forbidden {
target,
runner_group_id,
..
} => Some(format!(
"Grant the App installation `Administration: Read and write` on {target} (or \
`Self-hosted runners: Read and write` for an organization), and use a runner \
group this installation may administer — runner group {runner_group_id} \
answered 403, which a GitHub-hosted group always does."
)),
Self::NotFound {
target,
runner_group_id,
..
} => Some(format!(
"Check that {target} is spelled correctly and still exists, and that runner \
group {runner_group_id} exists in it."
)),
Self::Rejected { target, .. } => Some(format!(
"Correct the runner name or the routing labels for {target}: the label set \
must hold at least one label."
)),
Self::Github(GithubError::AuthenticationFailed) => {
Some("Run `runner-manager auth login` to sign in again.".to_string())
}
Self::Github(GithubError::Decode { .. } | GithubError::Malformed { .. }) => Some(
"Do not retry this registration: GitHub's answer could not be read, and \
`generate-jitconfig` answers 201 by creating the runner — so each further \
attempt can leave another registered runner that never comes online. \
Check the target's self-hosted runner list for offline runners matching \
this name and remove them, then report the response shape: this means \
GitHub's payload changed or the request body could not be built."
.to_string(),
),
_ => None,
}
}
}
#[async_trait::async_trait]
pub trait JitGateway: fmt::Debug + Send + Sync {
async fn generate_jit_config(
&self,
target: &ScaleTarget,
request: &JitRunnerRequest,
cancel: &CancelToken,
) -> Result<JitRegistration, JitError>;
}
pub struct RestJit {
client: Arc<AuthenticatedClient>,
requests_issued: AtomicU64,
}
impl fmt::Debug for RestJit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RestJit")
.field(
"requests_issued",
&self.requests_issued.load(Ordering::Relaxed),
)
.finish_non_exhaustive()
}
}
impl RestJit {
#[must_use]
pub fn new(client: Arc<AuthenticatedClient>) -> Self {
Self {
client,
requests_issued: AtomicU64::new(0),
}
}
#[must_use]
pub fn requests_issued(&self) -> u64 {
self.requests_issued.load(Ordering::SeqCst)
}
#[must_use]
pub fn path(target: &ScaleTarget) -> String {
match target {
ScaleTarget::Repository(repo) => {
format!("/repos/{}/{}{JITCONFIG_PATH}", repo.owner(), repo.repo())
}
ScaleTarget::Organization(org) => {
format!("/orgs/{}{JITCONFIG_PATH}", org.as_str())
}
}
}
fn classify(error: GithubError, target: &ScaleTarget, runner_group_id: u64) -> JitError {
if let Some(limit) = RateLimited::detect(&error) {
return JitError::RateLimited(limit);
}
let target = target.slug();
match &error {
GithubError::Forbidden { message, .. } => JitError::Forbidden {
target,
runner_group_id,
message: message.clone(),
},
GithubError::Status {
status: 404,
message,
..
} => JitError::NotFound {
target,
runner_group_id,
message: message.clone(),
},
GithubError::Status {
status: 422,
message,
..
} => JitError::Rejected {
target,
message: message.clone(),
},
_ => JitError::Github(error),
}
}
fn from_inventory(
error: InventoryError,
target: &ScaleTarget,
runner_group_id: u64,
) -> JitError {
match error {
InventoryError::Cancelled => JitError::Cancelled,
InventoryError::RateLimited(limit) => JitError::RateLimited(limit),
InventoryError::Github(error) => Self::classify(error, target, runner_group_id),
}
}
}
#[async_trait::async_trait]
impl JitGateway for RestJit {
async fn generate_jit_config(
&self,
target: &ScaleTarget,
request: &JitRunnerRequest,
cancel: &CancelToken,
) -> Result<JitRegistration, JitError> {
let group = request.runner_group_id();
let api_request = ApiRequest::post_json(Self::path(target), &request.body())
.map_err(|error| Self::classify(error, target, group))?;
let response = cancel
.run(async {
self.requests_issued.fetch_add(1, Ordering::SeqCst);
self.client
.send(&api_request)
.await
.map_err(InventoryError::from)
})
.await
.map_err(|error| Self::from_inventory(error, target, group))?;
if response.status().as_u16() != CREATED {
tracing::warn!(
status = response.status().as_u16(),
expected = CREATED,
"`generate-jitconfig` answered a success status other than 201; the \
registration is still decoded, but this endpoint has always answered 201"
);
}
let decoded: JitResponse = response.json().map_err(JitError::Github)?;
let mut raw = decoded.encoded_jit_config;
let config = EncodedJitConfig::new(raw.as_str());
raw.zeroize();
tracing::debug!(
target = %target,
runner_id = decoded.runner.id,
runner_name = %decoded.runner.name,
runner_group_id = decoded.runner.runner_group_id,
config_bytes = config.len(),
"registered a just-in-time runner"
);
Ok(JitRegistration::new(
config,
JitRunner {
id: decoded.runner.id,
name: decoded.runner.name,
os: decoded.runner.os,
status: decoded.runner.status,
busy: decoded.runner.busy,
runner_group_id: decoded.runner.runner_group_id,
labels: decoded
.runner
.labels
.into_iter()
.map(|label| label.name)
.collect(),
},
))
}
}
#[derive(Debug, Deserialize)]
struct JitResponse {
encoded_jit_config: String,
runner: RawJitRunner,
}
#[derive(Debug, Deserialize)]
struct RawJitRunner {
id: u64,
#[serde(default)]
name: String,
#[serde(default)]
os: String,
#[serde(default)]
status: String,
#[serde(default)]
busy: bool,
runner_group_id: Option<u64>,
#[serde(default)]
labels: Vec<RawJitLabel>,
}
#[derive(Debug, Deserialize)]
struct RawJitLabel {
name: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::{FIXTURE_TOKEN, TestClock};
use crate::{Endpoints, UserAccessToken};
use runner_manager_domain::model::{Arch, HostLabel, Os};
use serde_json::{Value, json};
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{body_json, method, path},
};
const FIXTURE_JIT_CONFIG: &str = concat!(
"eyJmaXh0dXJlIjoibm90LWEtcmVhbC1qaXQtY29uZmlndXJhdGlvbiIsIm5vdGUiOiJpZi",
"B0aGlzIHN0cmluZyBhcHBlYXJzIGluIGEgbG9nIHRoZSByZWRhY3Rpb24gZmFpbGVkIn0"
);
fn repo_target() -> ScaleTarget {
ScaleTarget::repository("octo/dashboard").expect("a valid owner/repo")
}
fn org_target() -> ScaleTarget {
ScaleTarget::organization("octo-org").expect("a valid organization login")
}
fn both_scopes() -> Vec<ScaleTarget> {
vec![repo_target(), org_target()]
}
fn gateway(server: &MockServer) -> RestJit {
let client = AuthenticatedClient::new(
Endpoints::for_test_server(&server.uri()).expect("a valid test base"),
UserAccessToken::new(SecretString::from(FIXTURE_TOKEN)),
Arc::new(TestClock::default()),
)
.expect("the HTTP client builds");
RestJit::new(Arc::new(client))
}
fn request() -> JitRunnerRequest {
JitRunnerRequest::new(
"rm-home-win-x64-0001",
3,
["rm-home-win-x64", "self-hosted"],
)
}
fn created_body(labels: &[&str]) -> Value {
json!({
"runner": {
"id": 73,
"name": "rm-home-win-x64-0001",
"os": "windows",
"status": "offline",
"busy": false,
"runner_group_id": 3,
"labels": labels
.iter()
.map(|name| json!({ "id": 1, "name": name, "type": "read-only" }))
.collect::<Vec<_>>()
},
"encoded_jit_config": FIXTURE_JIT_CONFIG
})
}
async fn mount_created(server: &MockServer, target: &ScaleTarget, body: Value) {
Mock::given(method("POST"))
.and(path(RestJit::path(target)))
.respond_with(ResponseTemplate::new(201).set_body_json(body))
.mount(server)
.await;
}
async fn mount_failure(server: &MockServer, target: &ScaleTarget, status: u16, message: &str) {
Mock::given(method("POST"))
.and(path(RestJit::path(target)))
.respond_with(
ResponseTemplate::new(status).set_body_json(json!({ "message": message })),
)
.mount(server)
.await;
}
#[tokio::test]
async fn a_201_decodes_into_the_configuration_and_the_runner_at_either_scope() {
for target in both_scopes() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(RestJit::path(&target)))
.and(body_json(json!({
"name": "rm-home-win-x64-0001",
"runner_group_id": 3,
"labels": ["rm-home-win-x64", "self-hosted"],
"work_folder": "_work"
})))
.respond_with(
ResponseTemplate::new(201)
.set_body_json(created_body(&["rm-home-win-x64", "self-hosted"])),
)
.mount(&server)
.await;
let gateway = gateway(&server);
let registration = gateway
.generate_jit_config(&target, &request(), &CancelToken::new())
.await
.unwrap_or_else(|error| panic!("a 201 at {target}: {error}"));
assert_eq!(
registration.config().expose(),
FIXTURE_JIT_CONFIG,
"the encoded configuration must survive the round trip at {target}"
);
assert_eq!(registration.runner().id, 73);
assert_eq!(registration.runner().name, "rm-home-win-x64-0001");
assert_eq!(
registration.runner().runner_group_id,
Some(3),
"the runner reference carries the group it was registered in, which \
`c3`'s inventory shape has no field for"
);
assert_eq!(
registration.runner().labels,
vec!["rm-home-win-x64".to_string(), "self-hosted".to_string()],
"no labels are added implicitly, so the 201 carries exactly what was sent"
);
assert_eq!(gateway.requests_issued(), 1);
}
}
#[tokio::test]
async fn a_success_status_other_than_201_is_still_decoded() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(RestJit::path(&repo_target())))
.respond_with(ResponseTemplate::new(200).set_body_json(created_body(&["a"])))
.mount(&server)
.await;
let registration = gateway(&server)
.generate_jit_config(&repo_target(), &request(), &CancelToken::new())
.await
.expect("a 200 carries the same body a 201 does and must not be fatal");
assert_eq!(registration.config().expose(), FIXTURE_JIT_CONFIG);
assert_ne!(
200, CREATED,
"the fixture has to be a status the code notices, or this proves nothing"
);
}
#[test]
fn the_two_scopes_differ_only_in_the_path() {
assert_eq!(
RestJit::path(&repo_target()),
"/repos/octo/dashboard/actions/runners/generate-jitconfig"
);
assert_eq!(
RestJit::path(&org_target()),
"/orgs/octo-org/actions/runners/generate-jitconfig"
);
assert!(
RestJit::path(&repo_target()).ends_with(JITCONFIG_PATH)
&& RestJit::path(&org_target()).ends_with(JITCONFIG_PATH),
"one suffix, two prefixes -- that is the whole of `v1`'s organization finding"
);
}
#[test]
fn the_request_body_is_exactly_the_four_documented_keys() {
let body = serde_json::to_value(request().body()).expect("the body serialises");
let object = body.as_object().expect("a JSON object");
let mut keys: Vec<&str> = object.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(
keys,
vec!["labels", "name", "runner_group_id", "work_folder"],
"`04-subsystem-contracts.md` types this body as {{name, runner_group_id, \
labels, work_folder}}; an extra key is an untested request and a missing \
`runner_group_id` is a 422"
);
assert_eq!(object["runner_group_id"], json!(3));
assert_eq!(object["work_folder"], json!("_work"));
let other = JitRunnerRequest::new("n", 99, ["a"]);
assert_eq!(
serde_json::to_value(other.body()).expect("serialises")["runner_group_id"],
json!(99),
"any administrable group id works, so nothing here may assume 1"
);
}
#[test]
fn a_policy_registers_exactly_its_own_routing_labels() {
let labels = RoutingLabels::derive(
&HostLabel::new("home").expect("a valid host label"),
Os::Windows,
Arch::X64,
);
let request = JitRunnerRequest::for_policy("runner-1", 1, &labels);
assert_eq!(request.labels(), &["rm-home-win-x64".to_string()]);
assert!(
!request.labels().iter().any(|label| label == "self-hosted"),
"`v1` established that no labels are added implicitly; adding one here would \
make a runner answer a `runs-on` the operator never asked it to"
);
assert!(
request
.labels()
.iter()
.all(|l| l == &l.to_ascii_lowercase()),
"GitHub stores labels lower-cased, so what is sent and what is stored must be \
the same string"
);
}
#[tokio::test]
async fn each_failure_status_is_a_distinct_outcome_and_none_is_retried() {
for target in both_scopes() {
let server = MockServer::start().await;
mount_failure(
&server,
&target,
403,
"GitHub hosted runner groups cannot be modified",
)
.await;
let refused = gateway(&server);
let error = refused
.generate_jit_config(&target, &request(), &CancelToken::new())
.await
.expect_err("a 403 is a failure");
assert!(
matches!(
error,
JitError::Forbidden {
runner_group_id: 3,
..
}
),
"a 403 must be its own outcome and must name the group: {error:?}"
);
assert!(error.is_terminal(), "a 403 is terminal");
assert!(
error.operator_action().is_some(),
"terminal and operator-actionable: a failure nobody can act on and nothing \
retries is a dead end"
);
assert_eq!(
refused.requests_issued(),
1,
"no code path may retry a 403; `d17` is the record of what it means"
);
let server = MockServer::start().await;
mount_failure(&server, &target, 404, "Not Found").await;
let missing = gateway(&server);
let error = missing
.generate_jit_config(&target, &request(), &CancelToken::new())
.await
.expect_err("a 404 is a failure");
assert!(
matches!(
error,
JitError::NotFound {
runner_group_id: 3,
..
}
),
"a 404 must be its own outcome: {error:?}"
);
assert!(error.is_terminal());
assert_eq!(missing.requests_issued(), 1);
let server = MockServer::start().await;
mount_failure(
&server,
&target,
422,
"Invalid property /labels: 1 item required; only 0 were supplied",
)
.await;
let rejected = gateway(&server);
let error = rejected
.generate_jit_config(&target, &request(), &CancelToken::new())
.await
.expect_err("a 422 is a failure");
assert!(
matches!(error, JitError::Rejected { .. }),
"a 422 must be its own outcome: {error:?}"
);
assert!(error.is_terminal());
assert_eq!(rejected.requests_issued(), 1);
}
}
#[tokio::test]
async fn an_unusable_runner_group_is_reported_differently_for_403_and_404() {
let target = org_target();
let server = MockServer::start().await;
mount_failure(
&server,
&target,
403,
"GitHub hosted runner groups cannot be modified",
)
.await;
let hosted_group = gateway(&server)
.generate_jit_config(
&target,
&JitRunnerRequest::new("n", 2, ["a"]),
&CancelToken::new(),
)
.await
.expect_err("group 2 is not administrable");
let server = MockServer::start().await;
mount_failure(&server, &target, 404, "Not Found").await;
let missing_group = gateway(&server)
.generate_jit_config(
&target,
&JitRunnerRequest::new("n", 99_999, ["a"]),
&CancelToken::new(),
)
.await
.expect_err("group 99999 does not exist");
assert!(matches!(
hosted_group,
JitError::Forbidden {
runner_group_id: 2,
..
}
));
assert!(matches!(
missing_group,
JitError::NotFound {
runner_group_id: 99_999,
..
}
));
assert_ne!(
hosted_group.operator_action(),
missing_group.operator_action(),
"the two answers need different remedies: one is a permission on an existing \
group, the other is a group that is not there"
);
assert!(
hosted_group.to_string().contains("403"),
"the 403 message must explain that a GitHub-hosted group always answers this"
);
assert!(
missing_group.to_string().contains("404"),
"and the 404 message must explain the difference in the other direction"
);
}
#[tokio::test]
async fn a_rate_limit_wearing_a_403_is_not_a_permissions_refusal() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(RestJit::path(&repo_target())))
.respond_with(
ResponseTemplate::new(403)
.insert_header("retry-after", "60")
.set_body_json(json!({
"message": "You have exceeded a secondary rate limit"
})),
)
.mount(&server)
.await;
let error = gateway(&server)
.generate_jit_config(&repo_target(), &request(), &CancelToken::new())
.await
.expect_err("a rate limit is a failure");
assert!(
error.rate_limited().is_some(),
"`c3`'s detector owns this decision and it said rate limit: {error:?}"
);
assert!(
!error.is_terminal(),
"a rate limit resolves by waiting; marking it terminal never registers the runner"
);
assert!(error.operator_action().is_none());
}
#[tokio::test]
async fn a_cancelled_registration_issues_no_request() {
let server = MockServer::start().await;
mount_created(&server, &repo_target(), created_body(&["a"])).await;
let gateway = gateway(&server);
let cancel = CancelToken::new();
cancel.cancel();
let error = gateway
.generate_jit_config(&repo_target(), &request(), &cancel)
.await
.expect_err("a cancelled token withdraws the registration");
assert!(error.is_cancelled());
assert_eq!(
gateway.requests_issued(),
0,
"the count is of requests actually attempted, and a withdrawn one is not"
);
}
#[test]
fn the_configuration_is_absent_from_debug_and_display() {
let config = EncodedJitConfig::new(FIXTURE_JIT_CONFIG);
let debug = format!("{config:?}");
let display = format!("{config}");
assert!(
!debug.contains(FIXTURE_JIT_CONFIG),
"Debug leaked it: {debug}"
);
assert!(
!display.contains(FIXTURE_JIT_CONFIG),
"Display leaked it: {display}"
);
assert!(debug.contains(REDACTED) && display.contains(REDACTED));
assert!(
debug.contains(&format!("{} bytes", FIXTURE_JIT_CONFIG.len())),
"the length is useful and is not the secret: {debug}"
);
let registration = JitRegistration::new(
EncodedJitConfig::new(FIXTURE_JIT_CONFIG),
JitRunner {
id: 73,
name: "runner".into(),
os: "windows".into(),
status: "offline".into(),
busy: false,
runner_group_id: Some(1),
labels: vec!["rm-home-win-x64".into()],
},
);
let rendered = format!("{registration:?}");
assert!(
!rendered.contains(FIXTURE_JIT_CONFIG),
"the registration's Debug leaked it: {rendered}"
);
assert!(
rendered.contains("runner"),
"and still says something useful"
);
}
#[test]
fn the_redaction_assertions_would_catch_a_derived_debug_over_a_plain_string() {
#[derive(Debug)]
struct ConfigWithADerivedDebug {
#[allow(dead_code)]
encoded_jit_config: String,
}
let leaky = ConfigWithADerivedDebug {
encoded_jit_config: FIXTURE_JIT_CONFIG.to_string(),
};
assert!(
format!("{leaky:?}").contains(FIXTURE_JIT_CONFIG),
"the assertions above cannot see a plain-String secret rendered through a \
derived Debug, so every one of them is worthless"
);
}
#[test]
fn the_wrapper_scrubs_its_buffer() {
let mut config = EncodedJitConfig::new(FIXTURE_JIT_CONFIG);
assert_eq!(
config.expose(),
FIXTURE_JIT_CONFIG,
"the fixture has to be really in there, or the assertion below is vacuous"
);
config.scrub();
assert!(
config.expose().bytes().all(|byte| byte == 0),
"every byte of the buffer must be zero after a scrub, not merely unreachable"
);
assert!(!config.expose().contains("eyJ"));
assert_eq!(
config.len(),
FIXTURE_JIT_CONFIG.len(),
"`str::zeroize` overwrites in place rather than shortening, so the length is \
unchanged and every byte of it is zero"
);
}
#[tokio::test]
async fn an_error_never_carries_the_encoded_configuration() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(RestJit::path(&repo_target())))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"encoded_jit_config": FIXTURE_JIT_CONFIG,
"runner": { "name": "no id field, so this cannot decode" }
})))
.mount(&server)
.await;
let error = gateway(&server)
.generate_jit_config(&repo_target(), &request(), &CancelToken::new())
.await
.expect_err("a 201 missing `runner.id` cannot decode");
let rendered = format!("{error} {error:?}");
assert!(
!rendered.contains(FIXTURE_JIT_CONFIG),
"a decode failure must not carry the body it failed to decode: {rendered}"
);
assert!(
!rendered.contains("eyJ"),
"not even a prefix of it: {rendered}"
);
for status in [403_u16, 404, 422] {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(RestJit::path(&repo_target())))
.respond_with(ResponseTemplate::new(status).set_body_json(json!({
"message": "Resource not accessible by integration",
"encoded_jit_config": FIXTURE_JIT_CONFIG
})))
.mount(&server)
.await;
let error = gateway(&server)
.generate_jit_config(&repo_target(), &request(), &CancelToken::new())
.await
.expect_err("a failure status");
let rendered = format!("{error} {error:?}");
assert!(
!rendered.contains(FIXTURE_JIT_CONFIG),
"a {status} rendered a response body verbatim: {rendered}"
);
assert!(
rendered.contains("Resource not accessible by integration"),
"GitHub's message is what makes a {status} operator-actionable and must \
survive: {rendered}"
);
}
}
#[tokio::test]
async fn an_undecodable_registration_is_terminal_and_operator_actionable() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(RestJit::path(&repo_target())))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"encoded_jit_config": FIXTURE_JIT_CONFIG,
"runner": { "name": "no id field, so this cannot decode" }
})))
.mount(&server)
.await;
let error = gateway(&server)
.generate_jit_config(&repo_target(), &request(), &CancelToken::new())
.await
.expect_err("a 201 missing `runner.id` cannot decode");
assert!(
matches!(error, JitError::Github(GithubError::Decode { .. })),
"the undecodable 201 arrives through the transparent `#[from]`, which is \
what makes its terminality a question about `GithubError` rather than \
about a `JitError` variant: {error:?}"
);
assert!(
error.is_terminal(),
"a response body this client cannot parse will not parse on a retry, and \
every retry registers another runner that is then discarded"
);
let action = error
.operator_action()
.expect("a terminal outcome with no operator action is a dead end");
assert!(
action.contains("Do not retry"),
"the action has to say the one thing a caller must not do: {action}"
);
assert!(
!action.contains(FIXTURE_JIT_CONFIG) && !action.contains("eyJ"),
"the operator action is rendered wherever the error is, so it is bound by \
the same redaction rule as the error itself: {action}"
);
let malformed = JitError::Github(GithubError::Malformed {
what: "runner.id",
value: "not a number".to_string(),
});
assert!(malformed.is_terminal());
assert!(malformed.operator_action().is_some());
for still_retryable in [
JitError::Github(GithubError::AuthenticationLockout {
retry_after: std::time::Duration::from_secs(60),
}),
JitError::Cancelled,
] {
assert!(
!still_retryable.is_terminal(),
"{still_retryable:?} resolves on its own and must not be reported as \
terminal"
);
}
}
#[tokio::test]
async fn the_runner_reference_reports_the_labels_github_actually_stored() {
let server = MockServer::start().await;
mount_created(
&server,
&repo_target(),
created_body(&["self-hosted", "rm-home-win-x64"]),
)
.await;
let registration = gateway(&server)
.generate_jit_config(&repo_target(), &request(), &CancelToken::new())
.await
.expect("a 201");
assert_eq!(
registration.runner().labels,
vec!["self-hosted".to_string(), "rm-home-win-x64".to_string()],
"what GitHub stored, in the order GitHub returned it -- `v1` established that \
the order is not the order requested"
);
}
}