use std::fmt;
use jiff::Timestamp;
use secrecy::SecretString;
use serde::{Deserialize, Deserializer, Serialize};
macro_rules! id_type {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
#[derive(
Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
)]
#[serde(transparent)]
pub struct $name(pub i64);
impl $name {
pub const fn get(self) -> i64 {
self.0
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl From<i64> for $name {
fn from(value: i64) -> Self {
Self(value)
}
}
impl From<$name> for i64 {
fn from(value: $name) -> Self {
value.0
}
}
};
}
id_type!(
ScaleSetId
);
id_type!(
RunnerGroupId
);
id_type!(
RunnerId
);
id_type!(
MessageId
);
id_type!(
JobRequestId
);
impl RunnerGroupId {
pub const DEFAULT: Self = Self(1);
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SessionId(String);
impl SessionId {
#[cfg(feature = "testing")]
pub(crate) fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for SessionId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<str> for SessionId {
fn as_ref(&self) -> &str {
&self.0
}
}
pub const DEFAULT_RUNNER_GROUP: &str = "default";
pub const LABEL_TYPE_SYSTEM: &str = "System";
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Label {
pub name: String,
#[serde(rename = "type", default)]
pub label_type: String,
}
impl Label {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
label_type: LABEL_TYPE_SYSTEM.to_owned(),
}
}
}
impl From<&str> for Label {
fn from(name: &str) -> Self {
Self::new(name)
}
}
impl From<String> for Label {
fn from(name: String) -> Self {
Self::new(name)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
#[non_exhaustive]
pub struct RunnerGroup {
pub id: RunnerGroupId,
pub name: String,
pub size: i64,
#[serde(rename = "isDefaultGroup")]
pub is_default: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
#[non_exhaustive]
pub struct RunnerSetting {
pub disable_update: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
#[non_exhaustive]
pub struct ScaleSetStatistics {
#[serde(deserialize_with = "count")]
pub total_available_jobs: u32,
#[serde(deserialize_with = "count")]
pub total_acquired_jobs: u32,
#[serde(deserialize_with = "count")]
pub total_assigned_jobs: u32,
#[serde(deserialize_with = "count")]
pub total_running_jobs: u32,
#[serde(deserialize_with = "count")]
pub total_registered_runners: u32,
#[serde(deserialize_with = "count")]
pub total_busy_runners: u32,
#[serde(deserialize_with = "count")]
pub total_idle_runners: u32,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
#[non_exhaustive]
pub struct RunnerScaleSet {
pub id: ScaleSetId,
pub name: String,
pub runner_group_id: RunnerGroupId,
pub runner_group_name: String,
pub labels: Vec<Label>,
#[serde(rename = "RunnerSetting")]
pub runner_setting: RunnerSetting,
#[serde(deserialize_with = "optional_time")]
pub created_on: Option<Timestamp>,
pub runner_jit_config_url: Option<String>,
pub statistics: Option<ScaleSetStatistics>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct RunnerScaleSetSpec {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub runner_group_id: Option<RunnerGroupId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub runner_group_name: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<Label>,
#[serde(rename = "RunnerSetting", skip_serializing_if = "Option::is_none")]
pub runner_setting: Option<RunnerSetting>,
}
impl RunnerScaleSetSpec {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: Some(name.into()),
..Self::default()
}
}
#[must_use]
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
#[must_use]
pub fn runner_group_id(mut self, id: RunnerGroupId) -> Self {
self.runner_group_id = Some(id);
self
}
#[must_use]
pub fn runner_group_name(mut self, name: impl Into<String>) -> Self {
self.runner_group_name = Some(name.into());
self
}
#[must_use]
pub fn label(mut self, label: impl Into<Label>) -> Self {
self.labels.push(label.into());
self
}
#[must_use]
pub fn labels<I, L>(mut self, labels: I) -> Self
where
I: IntoIterator<Item = L>,
L: Into<Label>,
{
self.labels.extend(labels.into_iter().map(Into::into));
self
}
#[must_use]
pub fn disable_update(mut self, disable: bool) -> Self {
self.runner_setting = Some(RunnerSetting {
disable_update: disable,
});
self
}
pub(crate) fn normalized(&self, creating: bool) -> Result<Self, String> {
let mut spec = self.clone();
if creating {
let name = match spec.name.as_deref() {
Some(name) if !name.trim().is_empty() => name.to_owned(),
_ => return Err("runner scale set name is required".into()),
};
if spec.labels.is_empty() {
spec.labels.push(Label::new(name));
}
spec.runner_setting.get_or_insert_default();
}
for label in &mut spec.labels {
if label.label_type.is_empty() {
LABEL_TYPE_SYSTEM.clone_into(&mut label.label_type);
}
}
Ok(spec)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct JitRunnerSetting {
pub name: String,
pub work_folder: String,
}
impl JitRunnerSetting {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
work_folder: String::new(),
}
}
pub fn unique(prefix: &str) -> Self {
Self::new(format!("{prefix}-{:012x}", fastrand::u64(..) >> 16))
}
#[must_use]
pub fn work_folder(mut self, folder: impl Into<String>) -> Self {
self.work_folder = folder.into();
self
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
#[non_exhaustive]
pub struct RunnerReference {
pub id: RunnerId,
pub name: String,
pub runner_scale_set_id: ScaleSetId,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct JitRunnerConfig {
pub runner: Option<RunnerReference>,
#[serde(rename = "encodedJITConfig")]
pub encoded_jit_config: SecretString,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase", default)]
#[non_exhaustive]
pub struct SessionInfo {
pub session_id: SessionId,
pub owner_name: String,
pub runner_scale_set: Option<RunnerScaleSet>,
pub message_queue_url: String,
pub message_queue_access_token: SecretString,
pub statistics: Option<ScaleSetStatistics>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct ScaleSetMessage {
pub id: MessageId,
pub statistics: Option<ScaleSetStatistics>,
pub jobs: Vec<JobMessage>,
}
impl ScaleSetMessage {
pub fn jobs_available(&self) -> impl Iterator<Item = &JobAvailable> {
self.jobs.iter().filter_map(|m| match m {
JobMessage::Available(j) => Some(j),
_ => None,
})
}
pub fn jobs_assigned(&self) -> impl Iterator<Item = &JobAssigned> {
self.jobs.iter().filter_map(|m| match m {
JobMessage::Assigned(j) => Some(j),
_ => None,
})
}
pub fn jobs_started(&self) -> impl Iterator<Item = &JobStarted> {
self.jobs.iter().filter_map(|m| match m {
JobMessage::Started(j) => Some(j),
_ => None,
})
}
pub fn jobs_completed(&self) -> impl Iterator<Item = &JobCompleted> {
self.jobs.iter().filter_map(|m| match m {
JobMessage::Completed(j) => Some(j),
_ => None,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "messageType")]
#[non_exhaustive]
pub enum JobMessage {
#[serde(rename = "JobAvailable")]
Available(JobAvailable),
#[serde(rename = "JobAssigned")]
Assigned(JobAssigned),
#[serde(rename = "JobStarted")]
Started(JobStarted),
#[serde(rename = "JobCompleted")]
Completed(JobCompleted),
#[serde(other)]
Unknown,
}
impl JobMessage {
pub fn job(&self) -> Option<&JobInfo> {
match self {
Self::Available(m) => Some(&m.job),
Self::Assigned(m) => Some(&m.job),
Self::Started(m) => Some(&m.job),
Self::Completed(m) => Some(&m.job),
Self::Unknown => None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
#[non_exhaustive]
pub struct JobInfo {
pub runner_request_id: JobRequestId,
pub repository_name: String,
pub owner_name: String,
pub job_id: String,
pub job_workflow_ref: String,
pub job_display_name: String,
pub workflow_run_id: i64,
pub event_name: String,
pub request_labels: Vec<String>,
#[serde(deserialize_with = "optional_time")]
pub queue_time: Option<Timestamp>,
#[serde(deserialize_with = "optional_time")]
pub scale_set_assign_time: Option<Timestamp>,
#[serde(deserialize_with = "optional_time")]
pub runner_assign_time: Option<Timestamp>,
#[serde(deserialize_with = "optional_time")]
pub finish_time: Option<Timestamp>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct JobAvailable {
#[serde(default)]
pub acquire_job_url: String,
#[serde(flatten)]
pub job: JobInfo,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct JobAssigned {
#[serde(flatten)]
pub job: JobInfo,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct JobStarted {
#[serde(default)]
pub runner_id: RunnerId,
#[serde(default)]
pub runner_name: String,
#[serde(flatten)]
pub job: JobInfo,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct JobCompleted {
#[serde(default)]
pub result: String,
#[serde(default)]
pub runner_id: RunnerId,
#[serde(default)]
pub runner_name: String,
#[serde(flatten)]
pub job: JobInfo,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RawMessage {
#[serde(default)]
pub message_id: MessageId,
#[serde(default)]
pub message_type: String,
#[serde(default)]
pub body: String,
#[serde(default)]
pub statistics: Option<ScaleSetStatistics>,
}
pub(crate) const MESSAGE_TYPE_JOB_MESSAGES: &str = "RunnerScaleSetJobMessages";
impl ScaleSetMessage {
pub(crate) fn from_raw(raw: RawMessage) -> Self {
let id = raw.message_id;
let entries: Vec<serde_json::Value> = if raw.message_type != MESSAGE_TYPE_JOB_MESSAGES {
tracing::warn!(message_id = %id, message_type = %raw.message_type, "skipping a message of an unknown type");
Vec::new()
} else if raw.body.is_empty() {
Vec::new()
} else {
serde_json::from_str(&raw.body).unwrap_or_else(|e| {
tracing::warn!(error = %e, message_id = %id, "skipping a message body that could not be decoded");
Vec::new()
})
};
let jobs = entries
.into_iter()
.map(|entry| {
JobMessage::deserialize(&entry).unwrap_or_else(|e| {
tracing::warn!(error = %e, message_id = %id, "skipping a job message that could not be decoded");
JobMessage::Unknown
})
})
.collect();
Self {
id,
statistics: raw.statistics,
jobs,
}
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct ListResponse<T> {
#[serde(default)]
pub count: usize,
#[serde(default = "Vec::new")]
pub value: Vec<T>,
}
fn optional_time<'de, D>(deserializer: D) -> Result<Option<Timestamp>, D::Error>
where
D: Deserializer<'de>,
{
let value = Option::<Timestamp>::deserialize(deserializer)?;
Ok(value.filter(|t| t.as_second() > GO_ZERO_TIME_SECS))
}
fn count<'de, D>(deserializer: D) -> Result<u32, D::Error>
where
D: Deserializer<'de>,
{
let value = i64::deserialize(deserializer)?;
Ok(u32::try_from(value.max(0)).unwrap_or(u32::MAX))
}
const GO_ZERO_TIME_SECS: i64 = -62_135_596_800;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decodes_job_messages_in_order() {
let body = serde_json::json!([
{"messageType": "JobAvailable", "acquireJobUrl": "u", "runnerRequestId": 1,
"queueTime": "2024-01-01T00:00:00Z", "finishTime": "0001-01-01T00:00:00Z"},
{"messageType": "JobStarted", "runnerRequestId": 2, "runnerId": 7, "runnerName": "r"},
{"messageType": "SomethingNew", "x": 1},
{"messageType": "JobCompleted", "runnerRequestId": 3, "result": "canceled"},
{"messageType": "JobAssigned", "runnerRequestId": 4, "requestLabels": ["a"]},
]);
let raw = RawMessage {
message_id: MessageId(5),
message_type: MESSAGE_TYPE_JOB_MESSAGES.into(),
body: body.to_string(),
statistics: Some(ScaleSetStatistics {
total_assigned_jobs: 3,
..Default::default()
}),
};
let msg = ScaleSetMessage::from_raw(raw);
assert_eq!(msg.id, MessageId(5));
assert_eq!(msg.jobs.len(), 5);
assert!(matches!(msg.jobs[2], JobMessage::Unknown));
let available: Vec<_> = msg.jobs_available().collect();
assert_eq!(available[0].acquire_job_url, "u");
assert_eq!(available[0].job.runner_request_id, JobRequestId(1));
assert!(available[0].job.queue_time.is_some());
assert_eq!(available[0].job.finish_time, None);
let started: Vec<_> = msg.jobs_started().collect();
assert_eq!(started[0].runner_id, RunnerId(7));
assert_eq!(msg.jobs_completed().next().unwrap().result, "canceled");
assert_eq!(
msg.jobs_assigned().next().unwrap().job.request_labels,
vec!["a"]
);
}
#[test]
fn tolerates_a_malformed_job_message() {
let body = serde_json::json!([
{"messageType": "JobStarted", "runnerId": "not a number"},
{"messageType": "JobAssigned", "runnerRequestId": 4},
]);
let raw = RawMessage {
message_id: MessageId(1),
message_type: MESSAGE_TYPE_JOB_MESSAGES.into(),
body: body.to_string(),
statistics: None,
};
let msg = ScaleSetMessage::from_raw(raw);
assert!(matches!(msg.jobs[0], JobMessage::Unknown));
assert_eq!(msg.jobs_assigned().count(), 1);
}
#[test]
fn skips_what_it_cannot_read() {
for (message_type, body) in [("Other", "[]"), (MESSAGE_TYPE_JOB_MESSAGES, "not json")] {
let raw = RawMessage {
message_id: MessageId(9),
message_type: message_type.into(),
body: body.into(),
statistics: Some(ScaleSetStatistics::default()),
};
let msg = ScaleSetMessage::from_raw(raw);
assert_eq!(msg.id, MessageId(9));
assert!(msg.jobs.is_empty());
assert!(msg.statistics.is_some());
}
}
#[test]
fn spec_defaults_label_to_name() {
let spec = RunnerScaleSetSpec::new("set").normalized(true).unwrap();
assert_eq!(spec.labels, vec![Label::new("set")]);
let spec = RunnerScaleSetSpec::default()
.label(Label {
name: "x".into(),
label_type: String::new(),
})
.normalized(false)
.unwrap();
assert_eq!(spec.labels[0].label_type, LABEL_TYPE_SYSTEM);
assert!(RunnerScaleSetSpec::default().normalized(true).is_err());
assert!(
RunnerScaleSetSpec::default()
.label(Label::new("x"))
.normalized(true)
.is_err()
);
}
#[test]
fn spec_serialization() {
let spec = RunnerScaleSetSpec::new("set")
.runner_group_id(RunnerGroupId(3))
.normalized(true)
.unwrap();
assert_eq!(
serde_json::to_value(&spec).unwrap(),
serde_json::json!({
"name": "set",
"runnerGroupId": 3,
"labels": [{"name": "set", "type": "System"}],
"RunnerSetting": {"disableUpdate": false}
})
);
}
#[test]
fn update_sends_only_what_was_set() {
let unchanged = RunnerScaleSetSpec::default().normalized(false).unwrap();
assert_eq!(
serde_json::to_value(&unchanged).unwrap(),
serde_json::json!({})
);
let enable = RunnerScaleSetSpec::default()
.disable_update(false)
.normalized(false)
.unwrap();
assert_eq!(
serde_json::to_value(&enable).unwrap(),
serde_json::json!({"RunnerSetting": {"disableUpdate": false}})
);
}
#[test]
fn secrets_are_redacted() {
let jit: JitRunnerConfig =
serde_json::from_str(r#"{"encodedJITConfig": "secret"}"#).unwrap();
assert!(!format!("{jit:?}").contains("secret"));
let session: SessionInfo =
serde_json::from_str(r#"{"messageQueueAccessToken": "secret"}"#).unwrap();
assert!(!format!("{session:?}").contains("secret"));
}
#[test]
fn session_ids_are_opaque() {
let session: SessionInfo = serde_json::from_str(r#"{"sessionId": "not-a-uuid"}"#).unwrap();
assert_eq!(session.session_id.as_str(), "not-a-uuid");
assert_eq!(session.session_id.to_string(), "not-a-uuid");
}
#[test]
fn counts_tolerate_out_of_range_values() {
let stats: ScaleSetStatistics =
serde_json::from_str(r#"{"totalAssignedJobs": -1, "totalRunningJobs": 99999999999}"#)
.unwrap();
assert_eq!(stats.total_assigned_jobs, 0);
assert_eq!(stats.total_running_jobs, u32::MAX);
}
}