scaleset/lib.rs
1//! A client for the GitHub Actions **Runner Scale Set** APIs.
2//!
3//! Scale sets let you autoscale self-hosted runners on any infrastructure,
4//! without Kubernetes or actions-runner-controller. This crate covers the
5//! whole API: scale sets, just-in-time (JIT) runner configs, and the message
6//! sessions that tell you how many runners are needed. It follows the
7//! protocol of GitHub's Go client,
8//! [`actions/scaleset`](https://github.com/actions/scaleset).
9//!
10//! # Overview
11//!
12//! 1. Build a [`Client`] with a GitHub App ([`Credentials::github_app`],
13//! preferred) or a personal access token.
14//! 2. Create a scale set with [`Client::create_runner_scale_set`]. Its name
15//! is the label workflows target with `runs-on`.
16//! 3. Run a [`Listener`](listener::Listener) with your
17//! [`Scaler`](listener::Scaler). It opens the message session, acquires
18//! jobs, acknowledges messages and recovers from failures.
19//! 4. In the scaler, keep
20//! [`total_assigned_jobs`](ScaleSetStatistics::total_assigned_jobs)
21//! runners available: start each with a config from
22//! [`Client::generate_jit_config`].
23//!
24//! ```no_run
25//! use std::collections::HashSet;
26//!
27//! use scaleset::listener::{Event, Listener, Scaler};
28//! use scaleset::{
29//! BoxError, Client, Credentials, ExposeSecret, JitRunnerSetting, RunnerScaleSetSpec,
30//! ScaleSetId,
31//! };
32//!
33//! /// Keeps one runner for every job assigned to the scale set.
34//! struct Autoscaler {
35//! client: Client,
36//! scale_set_id: ScaleSetId,
37//! /// Runners started and not finished yet.
38//! runners: HashSet<String>,
39//! }
40//!
41//! impl Scaler for Autoscaler {
42//! type Error = BoxError;
43//!
44//! async fn scale(&mut self, event: Event<'_>) -> Result<(), BoxError> {
45//! // Runners are ephemeral: each one leaves when its job completes.
46//! if let Some(message) = event.message() {
47//! for job in message.jobs_completed() {
48//! if let Some(name) = &job.runner_name {
49//! self.runners.remove(name);
50//! }
51//! }
52//! }
53//! let wanted = usize::try_from(event.statistics().total_assigned_jobs)?;
54//! while self.runners.len() < wanted {
55//! let setting = JitRunnerSetting::unique("runner");
56//! let jit = self.client.generate_jit_config(self.scale_set_id, &setting).await?;
57//! // Start a runner (process, container, VM) with
58//! // ACTIONS_RUNNER_INPUT_JITCONFIG set to this value.
59//! let _config = jit.encoded_jit_config.expose_secret();
60//! self.runners.insert(setting.name);
61//! }
62//! Ok(())
63//! }
64//! }
65//!
66//! # async fn run() -> Result<(), BoxError> {
67//! let pem = std::fs::read_to_string("app.pem")?;
68//! let client = Client::builder(
69//! "https://github.com/my-org",
70//! Credentials::github_app("Iv1.abc123", 12345, &pem)?,
71//! )
72//! .build()?;
73//!
74//! let scale_set = client
75//! .create_runner_scale_set(&RunnerScaleSetSpec::new("my-scale-set"))
76//! .await?;
77//!
78//! let mut scaler = Autoscaler { client: client.clone(), scale_set_id: scale_set.id, runners: HashSet::new() };
79//! Listener::new(client.clone(), scale_set.id, 10)
80//! .run(&mut scaler, async { let _ = tokio::signal::ctrl_c().await; })
81//! .await?;
82//!
83//! client.delete_runner_scale_set(scale_set.id).await?;
84//! # Ok(()) }
85//! ```
86//!
87//! # Layers
88//!
89//! | Layer | Use it to |
90//! | --- | --- |
91//! | [`Listener`](listener::Listener) + [`Scaler`](listener::Scaler) | run an autoscaler; the usual choice |
92//! | [`Subscription`](listener::Subscription) | drive the poll / acknowledge loop yourself |
93//! | [`MessageSession`] | call the message queue endpoints directly |
94//! | [`Client`] | manage scale sets, runners and JIT configs |
95//!
96//! # Transport and retries
97//!
98//! The HTTP client is yours ([`ClientBuilder::http_client`], with defaults
99//! from [`http_client_builder`]); retries belong to the SDK
100//! ([`RetryPolicy`]). Transport errors, 5xx other than 501, and 429 are
101//! retried four times with jittered exponential backoff by default.
102//! Rate-limited responses come back as a [`RateLimitError`] (see
103//! [`Error::rate_limit`]) and are retried according to [`RateLimitPolicy`].
104//! [`Client::with_retry_policy`] changes the policy for some calls only.
105//!
106//! # Testing
107//!
108//! With the `testing` feature, [`testing::Emulator`] runs a stateful
109//! emulator of GitHub and the Actions service in your test, so an
110//! autoscaler can be tested end to end without network access.
111//!
112//! # Logging
113//!
114//! The crate logs through [`tracing`]; each HTTP request runs in a
115//! `scaleset.request` span. Install a subscriber to see token refreshes,
116//! polls and retries.
117//!
118//! # Features
119//!
120//! * `rustls` (default): TLS through rustls.
121//! * `native-tls`: TLS through the platform's library.
122//! * `testing`: [`testing::Emulator`] and the `scaleset-emulator` binary.
123//!
124//! Enable `rustls` or `native-tls`; without TLS the client cannot connect.
125
126#![cfg_attr(docsrs, feature(doc_cfg))]
127
128mod auth;
129mod client;
130mod config;
131mod error;
132pub mod listener;
133mod retry;
134mod session;
135#[cfg(feature = "testing")]
136#[cfg_attr(docsrs, doc(cfg(feature = "testing")))]
137pub mod testing;
138mod transport;
139mod types;
140
141pub use auth::{Credentials, JwtProvider, Rs256Signer, SignerJwtProvider};
142pub use client::{Client, ClientBuilder, MESSAGE_POLL_TIMEOUT, SystemInfo, http_client_builder};
143pub use config::{FORCE_GHES_ENV, GitHubConfigUrl, Scope};
144pub use error::{
145 ActionsException, ApiError, ApiErrorKind, BoxError, Error, RateLimitError, RateLimitKind,
146 Result,
147};
148pub use retry::{RateLimitPolicy, RateLimitRule, RetryPolicy};
149pub use session::MessageSession;
150pub use types::{
151 DEFAULT_RUNNER_GROUP, JitRunnerConfig, JitRunnerSetting, JobAssigned, JobAvailable,
152 JobCompleted, JobInfo, JobMessage, JobRequestId, JobStarted, LABEL_TYPE_SYSTEM, Label,
153 MessageId, RunnerGroup, RunnerGroupId, RunnerId, RunnerReference, RunnerScaleSet,
154 RunnerScaleSetSpec, RunnerSetting, ScaleSetId, ScaleSetMessage, ScaleSetStatistics, SessionId,
155 SessionInfo,
156};
157
158/// Re-exported so callers can build an HTTP client without adding the
159/// dependency themselves.
160pub use reqwest;
161/// Re-exported for [`SecretString`] fields such as
162/// [`JitRunnerConfig::encoded_jit_config`].
163pub use secrecy::{self, ExposeSecret, SecretString};
164
165// The client, its sessions and the listener are shared across tasks.
166const _: () = {
167 const fn assert_send_sync<T: Send + Sync>() {}
168 assert_send_sync::<Client>();
169 assert_send_sync::<MessageSession>();
170 assert_send_sync::<Credentials>();
171 assert_send_sync::<Error>();
172 assert_send_sync::<RetryPolicy>();
173 assert_send_sync::<listener::Listener>();
174 assert_send_sync::<listener::JobTracker>();
175 assert_send_sync::<listener::MaxRunners>();
176 assert_send_sync::<listener::Error>();
177};
178
179/// Compiles the README's examples as doctests.
180#[cfg(doctest)]
181#[doc = include_str!("../README.md")]
182pub struct ReadmeDoctests;