Skip to main content

scaleset/
types.rs

1//! Data types exchanged with the scale set APIs.
2
3use std::fmt;
4
5use jiff::Timestamp;
6use secrecy::SecretString;
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9macro_rules! id_type {
10    ($(#[$meta:meta])* $name:ident) => {
11        $(#[$meta])*
12        #[derive(
13            Debug, Default, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
14        )]
15        #[serde(transparent)]
16        pub struct $name(pub i64);
17
18        impl $name {
19            /// Returns the raw value.
20            pub const fn get(self) -> i64 {
21                self.0
22            }
23        }
24
25        impl fmt::Display for $name {
26            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27                fmt::Display::fmt(&self.0, f)
28            }
29        }
30
31        impl std::str::FromStr for $name {
32            type Err = std::num::ParseIntError;
33
34            fn from_str(s: &str) -> Result<Self, Self::Err> {
35                s.parse().map(Self)
36            }
37        }
38
39        impl From<i64> for $name {
40            fn from(value: i64) -> Self {
41                Self(value)
42            }
43        }
44
45        impl From<$name> for i64 {
46            fn from(value: $name) -> Self {
47                value.0
48            }
49        }
50    };
51}
52
53id_type!(
54    /// Identifies a runner scale set.
55    ScaleSetId
56);
57id_type!(
58    /// Identifies a runner group.
59    RunnerGroupId
60);
61id_type!(
62    /// Identifies a registered runner.
63    RunnerId
64);
65id_type!(
66    /// Identifies a message in a session's message queue.
67    MessageId
68);
69id_type!(
70    /// Identifies a job request (`runnerRequestId`), as passed to
71    /// [`MessageSession::acquire_jobs`](crate::MessageSession::acquire_jobs).
72    JobRequestId
73);
74
75impl RunnerGroupId {
76    /// The ID of the `Default` runner group.
77    pub const DEFAULT: Self = Self(1);
78}
79
80/// Identifies a message session.
81///
82/// It is opaque: the service issues it and the client passes it back as is,
83/// so it is kept as text rather than parsed. Today's values are UUIDs.
84#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
85#[serde(transparent)]
86pub struct SessionId(String);
87
88impl SessionId {
89    #[cfg(feature = "testing")]
90    pub(crate) fn new(id: impl Into<String>) -> Self {
91        Self(id.into())
92    }
93
94    /// The ID as text.
95    pub fn as_str(&self) -> &str {
96        &self.0
97    }
98}
99
100impl fmt::Display for SessionId {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        f.write_str(&self.0)
103    }
104}
105
106impl AsRef<str> for SessionId {
107    fn as_ref(&self) -> &str {
108        &self.0
109    }
110}
111
112/// Name of the default runner group.
113pub const DEFAULT_RUNNER_GROUP: &str = "default";
114
115/// Label type the service assigns to labels created through this API.
116pub const LABEL_TYPE_SYSTEM: &str = "System";
117
118/// A label workflows use in `runs-on` to target a scale set.
119#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
120#[non_exhaustive]
121pub struct Label {
122    /// The label text.
123    pub name: String,
124    /// The label type. Empty values are sent as [`LABEL_TYPE_SYSTEM`].
125    #[serde(rename = "type", default)]
126    pub label_type: String,
127}
128
129impl Label {
130    /// A label of type [`LABEL_TYPE_SYSTEM`].
131    pub fn new(name: impl Into<String>) -> Self {
132        Self::with_type(name, LABEL_TYPE_SYSTEM)
133    }
134
135    /// A label of the given type.
136    pub fn with_type(name: impl Into<String>, label_type: impl Into<String>) -> Self {
137        Self {
138            name: name.into(),
139            label_type: label_type.into(),
140        }
141    }
142}
143
144impl From<&str> for Label {
145    fn from(name: &str) -> Self {
146        Self::new(name)
147    }
148}
149
150impl From<String> for Label {
151    fn from(name: String) -> Self {
152        Self::new(name)
153    }
154}
155
156/// A runner group.
157#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "camelCase", default)]
159#[non_exhaustive]
160pub struct RunnerGroup {
161    /// Runner group ID.
162    pub id: RunnerGroupId,
163    /// Runner group name.
164    pub name: String,
165    /// Number of runners in the group.
166    pub size: i64,
167    /// Whether this is the default group.
168    #[serde(rename = "isDefaultGroup")]
169    pub is_default: bool,
170}
171
172/// Runner settings of a scale set.
173#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase", default)]
175#[non_exhaustive]
176pub struct RunnerSetting {
177    /// Stops runners from updating themselves.
178    pub disable_update: bool,
179}
180
181/// Job and runner counts of a scale set.
182///
183/// Scale on [`total_assigned_jobs`](Self::total_assigned_jobs): it is the
184/// number of jobs assigned to the scale set, waiting or running, and is always
185/// current. Do not count job messages instead; batches are capped at 50
186/// messages and jobs can be reassigned.
187#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase", default)]
189#[non_exhaustive]
190pub struct ScaleSetStatistics {
191    /// Jobs available to the scale set.
192    #[serde(deserialize_with = "count")]
193    pub total_available_jobs: u32,
194    /// Jobs acquired by the scale set.
195    #[serde(deserialize_with = "count")]
196    pub total_acquired_jobs: u32,
197    /// Jobs assigned to the scale set, including running ones. This is the
198    /// number of runners the scale set needs.
199    #[serde(deserialize_with = "count")]
200    pub total_assigned_jobs: u32,
201    /// Jobs running on the scale set's runners.
202    #[serde(deserialize_with = "count")]
203    pub total_running_jobs: u32,
204    /// Runners registered with the scale set.
205    #[serde(deserialize_with = "count")]
206    pub total_registered_runners: u32,
207    /// Runners running a job.
208    #[serde(deserialize_with = "count")]
209    pub total_busy_runners: u32,
210    /// Runners waiting for a job.
211    #[serde(deserialize_with = "count")]
212    pub total_idle_runners: u32,
213}
214
215/// A runner scale set as returned by the service.
216#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
217#[serde(rename_all = "camelCase", default)]
218#[non_exhaustive]
219pub struct RunnerScaleSet {
220    /// Scale set ID.
221    pub id: ScaleSetId,
222    /// Scale set name.
223    pub name: String,
224    /// ID of the runner group the scale set belongs to.
225    pub runner_group_id: RunnerGroupId,
226    /// Name of the runner group the scale set belongs to.
227    pub runner_group_name: String,
228    /// Labels workflows can target.
229    pub labels: Vec<Label>,
230    /// Runner settings.
231    #[serde(rename = "RunnerSetting")]
232    pub runner_setting: RunnerSetting,
233    /// When the scale set was created.
234    #[serde(deserialize_with = "optional_time")]
235    pub created_on: Option<Timestamp>,
236    /// URL for generating JIT runner configs.
237    pub runner_jit_config_url: Option<String>,
238    /// Current statistics, when included.
239    pub statistics: Option<ScaleSetStatistics>,
240}
241
242/// The desired state of a scale set, for
243/// [`Client::create_runner_scale_set`](crate::Client::create_runner_scale_set)
244/// and [`Client::update_runner_scale_set`](crate::Client::update_runner_scale_set).
245///
246/// ```
247/// use scaleset::{RunnerGroupId, RunnerScaleSetSpec};
248///
249/// let spec = RunnerScaleSetSpec::new("my-scale-set")
250///     .runner_group_id(RunnerGroupId::DEFAULT)
251///     .labels(["linux", "x64"]);
252/// ```
253#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
254#[serde(rename_all = "camelCase")]
255#[non_exhaustive]
256pub struct RunnerScaleSetSpec {
257    /// Scale set name. Required on create.
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub name: Option<String>,
260    /// Runner group to place the scale set in.
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub runner_group_id: Option<RunnerGroupId>,
263    /// Runner group name.
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub runner_group_name: Option<String>,
266    /// Labels. On create, an empty list means "use the name as the label".
267    #[serde(skip_serializing_if = "Vec::is_empty")]
268    pub labels: Vec<Label>,
269    /// Runner settings. On update, `None` leaves them unchanged.
270    #[serde(rename = "RunnerSetting", skip_serializing_if = "Option::is_none")]
271    pub runner_setting: Option<RunnerSetting>,
272}
273
274impl RunnerScaleSetSpec {
275    /// A spec with the given name.
276    pub fn new(name: impl Into<String>) -> Self {
277        Self {
278            name: Some(name.into()),
279            ..Self::default()
280        }
281    }
282
283    /// Sets the name.
284    #[must_use]
285    pub fn name(mut self, name: impl Into<String>) -> Self {
286        self.name = Some(name.into());
287        self
288    }
289
290    /// Sets the runner group ID.
291    #[must_use]
292    pub fn runner_group_id(mut self, id: RunnerGroupId) -> Self {
293        self.runner_group_id = Some(id);
294        self
295    }
296
297    /// Sets the runner group name.
298    #[must_use]
299    pub fn runner_group_name(mut self, name: impl Into<String>) -> Self {
300        self.runner_group_name = Some(name.into());
301        self
302    }
303
304    /// Adds a label.
305    #[must_use]
306    pub fn label(mut self, label: impl Into<Label>) -> Self {
307        self.labels.push(label.into());
308        self
309    }
310
311    /// Adds labels.
312    ///
313    /// Multiple labels on GitHub Enterprise Server need 3.18 or later with
314    /// the `DistributedTask.AllowRunnerScaleSetCustomLabels` feature flag.
315    #[must_use]
316    pub fn labels<I, L>(mut self, labels: I) -> Self
317    where
318        I: IntoIterator<Item = L>,
319        L: Into<Label>,
320    {
321        self.labels.extend(labels.into_iter().map(Into::into));
322        self
323    }
324
325    /// Stops runners from updating themselves.
326    #[must_use]
327    pub fn disable_update(mut self) -> Self {
328        self.runner_setting = Some(RunnerSetting {
329            disable_update: true,
330        });
331        self
332    }
333
334    /// Lets runners update themselves. This is the default on create; on
335    /// update it turns updates back on.
336    #[must_use]
337    pub fn enable_update(mut self) -> Self {
338        self.runner_setting = Some(RunnerSetting {
339            disable_update: false,
340        });
341        self
342    }
343
344    /// The spec as sent on create: a name is required, and it becomes the
345    /// only label when there are none.
346    pub(crate) fn for_create(&self) -> Result<Self, String> {
347        let name = match self.name.as_deref() {
348            Some(name) if !name.trim().is_empty() => name,
349            _ => return Err("runner scale set name is required".into()),
350        };
351        let mut spec = self.for_update();
352        if spec.labels.is_empty() {
353            spec.labels.push(Label::new(name));
354        }
355        spec.runner_setting.get_or_insert_default();
356        Ok(spec)
357    }
358
359    /// The spec as sent on update: labels without a type get type `System`.
360    pub(crate) fn for_update(&self) -> Self {
361        let mut spec = self.clone();
362        for label in &mut spec.labels {
363            if label.label_type.is_empty() {
364                LABEL_TYPE_SYSTEM.clone_into(&mut label.label_type);
365            }
366        }
367        spec
368    }
369}
370
371/// Settings for a just-in-time runner.
372#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
373#[serde(rename_all = "camelCase")]
374#[non_exhaustive]
375pub struct JitRunnerSetting {
376    /// Runner name. Must be unique within the scale set.
377    pub name: String,
378    /// Runner work folder. Empty uses the runner's default.
379    pub work_folder: String,
380}
381
382impl JitRunnerSetting {
383    /// Settings for a runner with the given name.
384    pub fn new(name: impl Into<String>) -> Self {
385        Self {
386            name: name.into(),
387            work_folder: String::new(),
388        }
389    }
390
391    /// Settings for a runner named `prefix-` plus 12 random hex digits, so
392    /// names do not collide across restarts.
393    pub fn unique(prefix: &str) -> Self {
394        Self::new(format!("{prefix}-{:012x}", fastrand::u64(..) >> 16))
395    }
396
397    /// Sets the work folder.
398    #[must_use]
399    pub fn work_folder(mut self, folder: impl Into<String>) -> Self {
400        self.work_folder = folder.into();
401        self
402    }
403}
404
405/// A registered runner.
406#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
407#[serde(rename_all = "camelCase", default)]
408#[non_exhaustive]
409pub struct RunnerReference {
410    /// Runner ID.
411    pub id: RunnerId,
412    /// Runner name.
413    pub name: String,
414    /// Scale set the runner belongs to.
415    pub runner_scale_set_id: ScaleSetId,
416}
417
418/// A just-in-time runner configuration.
419///
420/// [`encoded_jit_config`](Self::encoded_jit_config) is a secret until the
421/// runner has used it; it is kept in a [`SecretString`], which is redacted
422/// from `Debug` output and wiped from memory on drop.
423#[derive(Debug, Clone, Default, Deserialize)]
424#[serde(default)]
425#[non_exhaustive]
426pub struct JitRunnerConfig {
427    /// The runner registered for this config.
428    pub runner: Option<RunnerReference>,
429    /// Pass to the runner as `ACTIONS_RUNNER_INPUT_JITCONFIG`, or as
430    /// `run.sh --jitconfig <value>`. Read it with
431    /// [`ExposeSecret::expose_secret`](secrecy::ExposeSecret::expose_secret).
432    #[serde(rename = "encodedJITConfig")]
433    pub encoded_jit_config: SecretString,
434}
435
436/// A message session, as created or refreshed by the service.
437#[derive(Debug, Clone, Default, Deserialize)]
438#[serde(rename_all = "camelCase", default)]
439#[non_exhaustive]
440pub struct SessionInfo {
441    /// Session ID.
442    pub session_id: SessionId,
443    /// Owner name given when the session was created.
444    pub owner_name: String,
445    /// The scale set the session belongs to.
446    pub runner_scale_set: Option<RunnerScaleSet>,
447    /// URL of the message queue.
448    pub message_queue_url: String,
449    /// Token for the message queue.
450    pub message_queue_access_token: SecretString,
451    /// Statistics when the session was created.
452    pub statistics: Option<ScaleSetStatistics>,
453}
454
455/// A batch of job messages from the message queue.
456#[derive(Debug, Clone, Default, PartialEq, Eq)]
457#[non_exhaustive]
458pub struct ScaleSetMessage {
459    /// Message ID. Acknowledge it with
460    /// [`MessageSession::delete_message`](crate::MessageSession::delete_message)
461    /// and pass it as `last_message_id` on the next poll.
462    pub id: MessageId,
463    /// Current statistics. Scale on these.
464    pub statistics: Option<ScaleSetStatistics>,
465    /// Job messages, in delivery order. At most 50 per batch.
466    pub jobs: Vec<JobMessage>,
467}
468
469impl ScaleSetMessage {
470    /// `JobAvailable` messages. Acquire the ones you want with
471    /// [`MessageSession::acquire_jobs`](crate::MessageSession::acquire_jobs).
472    pub fn jobs_available(&self) -> impl Iterator<Item = &JobAvailable> {
473        self.jobs.iter().filter_map(|m| match m {
474            JobMessage::Available(j) => Some(j),
475            _ => None,
476        })
477    }
478
479    /// `JobAssigned` messages.
480    pub fn jobs_assigned(&self) -> impl Iterator<Item = &JobAssigned> {
481        self.jobs.iter().filter_map(|m| match m {
482            JobMessage::Assigned(j) => Some(j),
483            _ => None,
484        })
485    }
486
487    /// `JobStarted` messages.
488    pub fn jobs_started(&self) -> impl Iterator<Item = &JobStarted> {
489        self.jobs.iter().filter_map(|m| match m {
490            JobMessage::Started(j) => Some(j),
491            _ => None,
492        })
493    }
494
495    /// `JobCompleted` messages.
496    pub fn jobs_completed(&self) -> impl Iterator<Item = &JobCompleted> {
497        self.jobs.iter().filter_map(|m| match m {
498            JobMessage::Completed(j) => Some(j),
499            _ => None,
500        })
501    }
502}
503
504/// One job message in a [`ScaleSetMessage`].
505#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
506#[serde(tag = "messageType")]
507#[non_exhaustive]
508pub enum JobMessage {
509    /// A job is available to the scale set.
510    #[serde(rename = "JobAvailable")]
511    Available(JobAvailable),
512    /// A job was assigned to the scale set.
513    #[serde(rename = "JobAssigned")]
514    Assigned(JobAssigned),
515    /// A runner started a job.
516    #[serde(rename = "JobStarted")]
517    Started(JobStarted),
518    /// A job finished, or its assignment was canceled.
519    #[serde(rename = "JobCompleted")]
520    Completed(JobCompleted),
521    /// A message type this crate does not know.
522    #[serde(other)]
523    Unknown,
524}
525
526impl JobMessage {
527    /// The job details, unless the message type is unknown.
528    pub fn job(&self) -> Option<&JobInfo> {
529        match self {
530            Self::Available(m) => Some(&m.job),
531            Self::Assigned(m) => Some(&m.job),
532            Self::Started(m) => Some(&m.job),
533            Self::Completed(m) => Some(&m.job),
534            Self::Unknown => None,
535        }
536    }
537}
538
539/// Details shared by all job messages.
540#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
541#[serde(rename_all = "camelCase", default)]
542#[non_exhaustive]
543pub struct JobInfo {
544    /// Job request ID, for [`MessageSession::acquire_jobs`](crate::MessageSession::acquire_jobs).
545    pub runner_request_id: JobRequestId,
546    /// Repository name.
547    pub repository_name: String,
548    /// Repository owner.
549    pub owner_name: String,
550    /// Job ID.
551    pub job_id: String,
552    /// Workflow reference.
553    pub job_workflow_ref: String,
554    /// Job display name.
555    pub job_display_name: String,
556    /// Workflow run ID.
557    pub workflow_run_id: i64,
558    /// Event that triggered the workflow.
559    pub event_name: String,
560    /// Labels the job requested.
561    pub request_labels: Vec<String>,
562    /// When the job was queued.
563    #[serde(deserialize_with = "optional_time")]
564    pub queue_time: Option<Timestamp>,
565    /// When the job was assigned to the scale set.
566    #[serde(deserialize_with = "optional_time")]
567    pub scale_set_assign_time: Option<Timestamp>,
568    /// When the job was assigned to a runner.
569    #[serde(deserialize_with = "optional_time")]
570    pub runner_assign_time: Option<Timestamp>,
571    /// When the job finished.
572    #[serde(deserialize_with = "optional_time")]
573    pub finish_time: Option<Timestamp>,
574}
575
576/// A job is available to the scale set.
577#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
578#[serde(rename_all = "camelCase")]
579#[non_exhaustive]
580pub struct JobAvailable {
581    /// URL for acquiring the job.
582    #[serde(default)]
583    pub acquire_job_url: String,
584    /// Job details.
585    #[serde(flatten)]
586    pub job: JobInfo,
587}
588
589/// A job was assigned to the scale set.
590#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
591#[non_exhaustive]
592pub struct JobAssigned {
593    /// Job details.
594    #[serde(flatten)]
595    pub job: JobInfo,
596}
597
598/// A runner started a job.
599#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
600#[serde(rename_all = "camelCase")]
601#[non_exhaustive]
602pub struct JobStarted {
603    /// The runner's ID.
604    #[serde(default)]
605    pub runner_id: RunnerId,
606    /// The runner's name.
607    #[serde(default)]
608    pub runner_name: String,
609    /// Job details.
610    #[serde(flatten)]
611    pub job: JobInfo,
612}
613
614/// A job finished, or its assignment was canceled before a runner took it.
615///
616/// A job can be assigned and canceled up to three times before it runs; each
617/// attempt produces its own messages.
618#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
619#[serde(rename_all = "camelCase")]
620#[non_exhaustive]
621pub struct JobCompleted {
622    /// Result, such as `succeeded`, `failed` or `canceled`.
623    #[serde(default)]
624    pub result: String,
625    /// The runner's ID, or `None` if no runner took the job.
626    #[serde(
627        default,
628        deserialize_with = "optional_id",
629        serialize_with = "id_or_zero"
630    )]
631    pub runner_id: Option<RunnerId>,
632    /// The runner's name, or `None` if no runner took the job.
633    #[serde(
634        default,
635        deserialize_with = "optional_name",
636        serialize_with = "name_or_empty"
637    )]
638    pub runner_name: Option<String>,
639    /// Job details.
640    #[serde(flatten)]
641    pub job: JobInfo,
642}
643
644/// Wire shape of a message queue response.
645#[derive(Debug, Deserialize)]
646#[serde(rename_all = "camelCase")]
647pub(crate) struct RawMessage {
648    #[serde(default, deserialize_with = "null_as_default")]
649    pub message_id: MessageId,
650    #[serde(default, deserialize_with = "null_as_default")]
651    pub message_type: String,
652    #[serde(default, deserialize_with = "null_as_default")]
653    pub body: String,
654    #[serde(default)]
655    pub statistics: Option<ScaleSetStatistics>,
656}
657
658pub(crate) const MESSAGE_TYPE_JOB_MESSAGES: &str = "RunnerScaleSetJobMessages";
659
660impl ScaleSetMessage {
661    /// Decodes a queue message. Anything it cannot read becomes a message
662    /// with no jobs, so it can still be acknowledged and its statistics
663    /// used. Failing instead would redeliver the same message forever.
664    pub(crate) fn from_raw(raw: &RawMessage) -> Self {
665        let id = raw.message_id;
666        let entries: Vec<serde_json::Value> = if raw.message_type != MESSAGE_TYPE_JOB_MESSAGES {
667            tracing::warn!(message_id = %id, message_type = %raw.message_type, "skipping a message of an unknown type");
668            Vec::new()
669        } else if raw.body.is_empty() {
670            Vec::new()
671        } else {
672            serde_json::from_str(&raw.body).unwrap_or_else(|e| {
673                tracing::warn!(error = %e, message_id = %id, "skipping a message body that could not be decoded");
674                Vec::new()
675            })
676        };
677        // One malformed entry must not make the rest of the batch undeliverable.
678        let jobs = entries
679            .into_iter()
680            .map(|entry| {
681                JobMessage::deserialize(&entry).unwrap_or_else(|e| {
682                    tracing::warn!(error = %e, message_id = %id, "skipping a job message that could not be decoded");
683                    JobMessage::Unknown
684                })
685            })
686            .collect();
687        Self {
688            id,
689            statistics: raw.statistics,
690            jobs,
691        }
692    }
693}
694
695/// `{ "count": n, "value": [...] }`
696#[derive(Debug, Deserialize)]
697pub(crate) struct ListResponse<T> {
698    #[serde(default)]
699    pub count: usize,
700    #[serde(default = "Vec::new")]
701    pub value: Vec<T>,
702}
703
704/// Deserializes an optional timestamp, mapping Go's zero time to `None`.
705fn optional_time<'de, D>(deserializer: D) -> Result<Option<Timestamp>, D::Error>
706where
707    D: Deserializer<'de>,
708{
709    let value = Option::<Timestamp>::deserialize(deserializer)?;
710    Ok(value.filter(|t| t.as_second() > GO_ZERO_TIME_SECS))
711}
712
713/// Deserializes a count, reading `null` as 0 and a negative or oversized
714/// value as the nearest `u32`, rather than failing the whole message.
715fn count<'de, D>(deserializer: D) -> Result<u32, D::Error>
716where
717    D: Deserializer<'de>,
718{
719    let value = Option::<i64>::deserialize(deserializer)?.unwrap_or(0);
720    Ok(u32::try_from(value.max(0)).unwrap_or(u32::MAX))
721}
722
723/// Deserializes a runner ID, mapping Go's zero value to `None`.
724fn optional_id<'de, D>(deserializer: D) -> Result<Option<RunnerId>, D::Error>
725where
726    D: Deserializer<'de>,
727{
728    let value = Option::<RunnerId>::deserialize(deserializer)?;
729    Ok(value.filter(|id| id.0 != 0))
730}
731
732/// Deserializes a runner name, mapping Go's zero value to `None`.
733fn optional_name<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
734where
735    D: Deserializer<'de>,
736{
737    let value = Option::<String>::deserialize(deserializer)?;
738    Ok(value.filter(|name| !name.is_empty()))
739}
740
741/// Serializes a missing runner ID as Go's zero value, as the service does.
742#[expect(clippy::ref_option, reason = "serde passes fields by reference")]
743fn id_or_zero<S: Serializer>(id: &Option<RunnerId>, serializer: S) -> Result<S::Ok, S::Error> {
744    id.unwrap_or_default().serialize(serializer)
745}
746
747/// Serializes a missing runner name as Go's zero value, as the service does.
748#[expect(clippy::ref_option, reason = "serde passes fields by reference")]
749fn name_or_empty<S: Serializer>(name: &Option<String>, serializer: S) -> Result<S::Ok, S::Error> {
750    name.as_deref().unwrap_or_default().serialize(serializer)
751}
752
753/// Deserializes `null` as the default value, as Go does.
754fn null_as_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
755where
756    D: Deserializer<'de>,
757    T: Deserialize<'de> + Default,
758{
759    Ok(Option::<T>::deserialize(deserializer)?.unwrap_or_default())
760}
761
762/// `0001-01-01T00:00:00Z` as a Unix timestamp.
763const GO_ZERO_TIME_SECS: i64 = -62_135_596_800;
764
765#[cfg(test)]
766mod tests {
767    use super::*;
768
769    #[test]
770    fn decodes_job_messages_in_order() {
771        let body = serde_json::json!([
772            {"messageType": "JobAvailable", "acquireJobUrl": "u", "runnerRequestId": 1,
773             "queueTime": "2024-01-01T00:00:00Z", "finishTime": "0001-01-01T00:00:00Z"},
774            {"messageType": "JobStarted", "runnerRequestId": 2, "runnerId": 7, "runnerName": "r"},
775            {"messageType": "SomethingNew", "x": 1},
776            {"messageType": "JobCompleted", "runnerRequestId": 3, "result": "canceled",
777             "runnerId": 0, "runnerName": ""},
778            {"messageType": "JobAssigned", "runnerRequestId": 4, "requestLabels": ["a"]},
779        ]);
780        let raw = RawMessage {
781            message_id: MessageId(5),
782            message_type: MESSAGE_TYPE_JOB_MESSAGES.into(),
783            body: body.to_string(),
784            statistics: Some(ScaleSetStatistics {
785                total_assigned_jobs: 3,
786                ..Default::default()
787            }),
788        };
789        let msg = ScaleSetMessage::from_raw(&raw);
790        assert_eq!(msg.id, MessageId(5));
791        assert_eq!(msg.jobs.len(), 5);
792        assert!(matches!(msg.jobs[2], JobMessage::Unknown));
793
794        let available: Vec<_> = msg.jobs_available().collect();
795        assert_eq!(available[0].acquire_job_url, "u");
796        assert_eq!(available[0].job.runner_request_id, JobRequestId(1));
797        assert!(available[0].job.queue_time.is_some());
798        assert_eq!(available[0].job.finish_time, None);
799
800        let started: Vec<_> = msg.jobs_started().collect();
801        assert_eq!(started[0].runner_id, RunnerId(7));
802        let completed = msg.jobs_completed().next().unwrap();
803        assert_eq!(completed.result, "canceled");
804        assert_eq!(
805            (completed.runner_id, completed.runner_name.as_deref()),
806            (None, None)
807        );
808        assert_eq!(
809            msg.jobs_assigned().next().unwrap().job.request_labels,
810            vec!["a"]
811        );
812    }
813
814    #[test]
815    fn tolerates_a_malformed_job_message() {
816        let body = serde_json::json!([
817            {"messageType": "JobStarted", "runnerId": "not a number"},
818            {"messageType": "JobAssigned", "runnerRequestId": 4},
819        ]);
820        let raw = RawMessage {
821            message_id: MessageId(1),
822            message_type: MESSAGE_TYPE_JOB_MESSAGES.into(),
823            body: body.to_string(),
824            statistics: None,
825        };
826        let msg = ScaleSetMessage::from_raw(&raw);
827        assert!(matches!(msg.jobs[0], JobMessage::Unknown));
828        assert_eq!(msg.jobs_assigned().count(), 1);
829    }
830
831    #[test]
832    fn skips_what_it_cannot_read() {
833        for (message_type, body) in [("Other", "[]"), (MESSAGE_TYPE_JOB_MESSAGES, "not json")] {
834            let raw = RawMessage {
835                message_id: MessageId(9),
836                message_type: message_type.into(),
837                body: body.into(),
838                statistics: Some(ScaleSetStatistics::default()),
839            };
840            let msg = ScaleSetMessage::from_raw(&raw);
841            assert_eq!(msg.id, MessageId(9));
842            assert!(msg.jobs.is_empty());
843            assert!(msg.statistics.is_some());
844        }
845    }
846
847    #[test]
848    fn spec_defaults_label_to_name() {
849        let spec = RunnerScaleSetSpec::new("set").for_create().unwrap();
850        assert_eq!(spec.labels, vec![Label::new("set")]);
851
852        let spec = RunnerScaleSetSpec::default()
853            .label(Label {
854                name: "x".into(),
855                label_type: String::new(),
856            })
857            .for_update();
858        assert_eq!(spec.labels[0].label_type, LABEL_TYPE_SYSTEM);
859
860        assert!(RunnerScaleSetSpec::default().for_create().is_err());
861        // A name is required on create even with labels.
862        assert!(
863            RunnerScaleSetSpec::default()
864                .label(Label::new("x"))
865                .for_create()
866                .is_err()
867        );
868    }
869
870    #[test]
871    fn spec_serialization() {
872        let spec = RunnerScaleSetSpec::new("set")
873            .runner_group_id(RunnerGroupId(3))
874            .for_create()
875            .unwrap();
876        assert_eq!(
877            serde_json::to_value(&spec).unwrap(),
878            serde_json::json!({
879                "name": "set",
880                "runnerGroupId": 3,
881                "labels": [{"name": "set", "type": "System"}],
882                "RunnerSetting": {"disableUpdate": false}
883            })
884        );
885    }
886
887    #[test]
888    fn update_sends_only_what_was_set() {
889        let unchanged = RunnerScaleSetSpec::default().for_update();
890        assert_eq!(
891            serde_json::to_value(&unchanged).unwrap(),
892            serde_json::json!({})
893        );
894
895        // An explicit `false` is sent, so updates can be turned back on.
896        let enable = RunnerScaleSetSpec::default().enable_update().for_update();
897        assert_eq!(
898            serde_json::to_value(&enable).unwrap(),
899            serde_json::json!({"RunnerSetting": {"disableUpdate": false}})
900        );
901    }
902
903    #[test]
904    fn secrets_are_redacted() {
905        let jit: JitRunnerConfig =
906            serde_json::from_str(r#"{"encodedJITConfig": "secret"}"#).unwrap();
907        assert!(!format!("{jit:?}").contains("secret"));
908
909        let session: SessionInfo =
910            serde_json::from_str(r#"{"messageQueueAccessToken": "secret"}"#).unwrap();
911        assert!(!format!("{session:?}").contains("secret"));
912    }
913
914    #[test]
915    fn session_ids_are_opaque() {
916        // Whatever the service sends is kept and passed back unchanged.
917        let session: SessionInfo = serde_json::from_str(r#"{"sessionId": "not-a-uuid"}"#).unwrap();
918        assert_eq!(session.session_id.as_str(), "not-a-uuid");
919        assert_eq!(session.session_id.to_string(), "not-a-uuid");
920    }
921
922    #[test]
923    fn counts_tolerate_out_of_range_values() {
924        let stats: ScaleSetStatistics =
925            serde_json::from_str(r#"{"totalAssignedJobs": -1, "totalRunningJobs": 99999999999}"#)
926                .unwrap();
927        assert_eq!(stats.total_assigned_jobs, 0);
928        assert_eq!(stats.total_running_jobs, u32::MAX);
929    }
930    #[test]
931    fn job_without_runner_uses_go_zero_values() {
932        let job = JobCompleted {
933            result: "canceled".into(),
934            ..JobCompleted::default()
935        };
936        let wire = serde_json::to_value(&job).unwrap();
937        assert_eq!(wire["runnerId"], 0, "{wire}");
938        assert_eq!(wire["runnerName"], "", "{wire}");
939        assert_eq!(serde_json::from_value::<JobCompleted>(wire).unwrap(), job);
940    }
941
942    #[test]
943    fn ids_parse_from_text() {
944        assert_eq!("42".parse::<RunnerId>(), Ok(RunnerId(42)));
945        assert!("x".parse::<ScaleSetId>().is_err());
946    }
947
948    #[test]
949    fn nulls_read_as_empty() {
950        let stats: ScaleSetStatistics =
951            serde_json::from_str(r#"{"totalAssignedJobs": null, "totalRunningJobs": 2}"#).unwrap();
952        assert_eq!(stats.total_assigned_jobs, 0);
953        assert_eq!(stats.total_running_jobs, 2);
954
955        let raw: RawMessage = serde_json::from_str(
956            r#"{"messageId": 3, "messageType": null, "body": null, "statistics": {"totalAssignedJobs": null}}"#,
957        )
958        .unwrap();
959        let msg = ScaleSetMessage::from_raw(&raw);
960        assert_eq!(msg.id, MessageId(3));
961        assert!(msg.jobs.is_empty());
962        assert_eq!(msg.statistics, Some(ScaleSetStatistics::default()));
963    }
964}