pub mod auth;
pub mod daemon;
pub mod host;
pub mod policy;
pub mod service;
pub mod status;
pub mod ui;
pub mod update;
pub mod workspace;
pub mod wsl;
use std::fmt;
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::Arc;
use clap::{Args, Parser, Subcommand};
use runner_manager_domain::model::{Clock, Host, StartMode, SystemClock};
use runner_manager_domain::store::{SqliteStore, Store};
use runner_manager_domain::workspace::WorkspaceKind;
use runner_manager_github::{AppRegistration, Endpoints};
use runner_manager_platform::paths::AppPaths;
use runner_manager_platform::secrets::{PlatformSecretStore, SecretScope, SecretStore};
pub const PUBLISHED_CLIENT_ID: &str = "Iv23liUGaKmwt8p3ZxRc";
pub const PUBLISHED_APP_SLUG: &str = "runner-manager-scaler";
pub const CLIENT_ID_VARIABLE: &str = "RUNNER_MANAGER_GITHUB_CLIENT_ID";
pub const APP_SLUG_VARIABLE: &str = "RUNNER_MANAGER_GITHUB_APP_SLUG";
pub const GITHUB_BASE_URL_VARIABLE: &str = "RUNNER_MANAGER_GITHUB_BASE_URL";
pub const DATABASE_FILE: &str = "runner-manager.sqlite3";
pub const DATA_DIR_VARIABLE: &str = "RUNNER_MANAGER_DATA_DIR";
pub const DEFAULT_HOST_CAPACITY: u16 = 1;
macro_rules! failure_taxonomy {
(
$(
$(#[$documentation:meta])*
$variant:ident = $code:literal => $name:literal,
)+
) => {
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(u8)]
pub enum Failure {
$(
$(#[$documentation])*
$variant = $code,
)+
}
impl Failure {
#[allow(
dead_code,
reason = "read by the distinctness proof in this file's tests"
)]
pub const ALL: &'static [Failure] = &[$(Failure::$variant,)+];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
$(Self::$variant => $name,)+
}
}
}
};
}
failure_taxonomy! {
Unclassified = 1 => "unclassified",
NotAuthenticated = 3 => "not_authenticated",
AuthenticationFailed = 4 => "authentication_failed",
AuthenticationLockout = 5 => "authentication_lockout",
AuthenticationDeclined = 6 => "authentication_declined",
GithubUnavailable = 7 => "github_unavailable",
GithubRefused = 8 => "github_refused",
InvalidArgument = 9 => "invalid_argument",
NotFound = 10 => "not_found",
Conflict = 11 => "conflict",
BudgetRefused = 12 => "budget_refused",
SecretStore = 13 => "secret_store",
LocalState = 14 => "local_state",
UnsupportedHost = 15 => "unsupported_host",
AppNotPublished = 16 => "app_not_published",
NotImplemented = 17 => "not_implemented",
AuthenticationExpired = 18 => "authentication_expired",
AppMisconfigured = 19 => "app_misconfigured",
UnusableResponse = 20 => "unusable_response",
UpgradePending = 21 => "upgrade_pending",
UpdateUnsupported = 22 => "update_unsupported",
UpdateFailed = 23 => "update_failed",
WslProvisioning = 24 => "wsl_provisioning",
}
impl Failure {
#[must_use]
pub const fn code(self) -> u8 {
self as u8
}
}
impl fmt::Display for Failure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliError {
class: Failure,
message: String,
remedy: Option<String>,
}
impl CliError {
#[must_use]
pub fn new(class: Failure, message: impl Into<String>) -> Self {
Self {
class,
message: message.into(),
remedy: None,
}
}
#[must_use]
pub fn with_remedy(
class: Failure,
message: impl Into<String>,
remedy: impl Into<String>,
) -> Self {
Self {
class,
message: message.into(),
remedy: Some(remedy.into()),
}
}
#[must_use]
pub const fn class(&self) -> Failure {
self.class
}
#[must_use]
#[allow(dead_code, reason = "read by the failure-copy tests in `auth`")]
pub fn message(&self) -> &str {
&self.message
}
#[must_use]
#[allow(dead_code, reason = "read by the failure-copy tests in `auth`")]
pub fn remedy(&self) -> Option<&str> {
self.remedy.as_deref()
}
pub fn render(&self, err: &mut dyn Write) -> io::Result<()> {
ui::Ui::new(Styling::for_stderr()).error(err, &self.message, self.remedy.as_deref())
}
}
impl fmt::Display for CliError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
pub const NO_OPERATOR_REMEDY: &str = "there is no command here that fixes this";
pub fn write_failed(what: &str) -> impl Fn(io::Error) -> CliError + Copy + '_ {
move |source| {
CliError::new(
Failure::Unclassified,
format!("cannot write {what}: {source}"),
)
}
}
#[derive(Debug, Parser)]
#[command(
name = "runner-manager",
version,
about = "Local-first autoscaling manager for ephemeral GitHub Actions self-hosted runners.",
long_about = None,
propagate_version = true,
disable_help_subcommand = true
)]
pub struct Cli {
#[arg(long, value_name = "DIR", global = true, env = DATA_DIR_VARIABLE)]
pub data_dir: Option<PathBuf>,
#[arg(
long,
value_name = "HOST",
global = true,
default_value = LOCAL_HOST_SELECTOR,
value_parser = HostSelector::parse,
)]
pub host: HostSelector,
#[command(subcommand)]
pub command: Command,
}
pub const LOCAL_HOST_SELECTOR: &str = "local";
pub const WSL_HOST_PREFIX: &str = "wsl:";
pub const HOST_OPTION: &str = "--host";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HostSelector {
Local,
Wsl(String),
}
impl HostSelector {
pub fn parse(raw: &str) -> Result<Self, String> {
if raw == LOCAL_HOST_SELECTOR {
return Ok(Self::Local);
}
if let Some(name) = raw.strip_prefix(WSL_HOST_PREFIX) {
if name.is_empty() {
return Err(format!(
"`{WSL_HOST_PREFIX}` needs the distribution's name after it, as \
`--host {WSL_HOST_PREFIX}Ubuntu`. `runner-manager wsl list` names the \
ones this machine has."
));
}
return Ok(Self::Wsl(name.to_string()));
}
Err(format!(
"expected `{LOCAL_HOST_SELECTOR}` or `{WSL_HOST_PREFIX}<distribution>`, not \
{raw:?}. `runner-manager wsl list` names the distributions this machine has."
))
}
#[must_use]
pub fn distribution(&self) -> Option<&str> {
match self {
Self::Local => None,
Self::Wsl(name) => Some(name),
}
}
}
impl fmt::Display for HostSelector {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Local => f.write_str(LOCAL_HOST_SELECTOR),
Self::Wsl(name) => write!(f, "{WSL_HOST_PREFIX}{name}"),
}
}
}
#[derive(Debug, Subcommand)]
pub enum Command {
#[command(subcommand)]
Auth(AuthCommand),
#[command(subcommand)]
Host(HostCommand),
#[command(subcommand)]
Repo(RepoCommand),
#[command(subcommand)]
Org(OrgCommand),
#[command(subcommand)]
Daemon(DaemonCommand),
#[command(subcommand)]
Service(ServiceCommand),
Tui,
Status(StatusArgs),
Update(UpdateArgs),
#[command(subcommand)]
Wsl(WslCommand),
#[command(subcommand, hide = true)]
WslHost(WslHostCommand),
}
#[derive(Debug, Subcommand)]
pub enum WslCommand {
List,
Install(WslInstallArgs),
Status(WslStatusArgs),
Detach(WslDetachArgs),
}
#[derive(Debug, Args)]
pub struct WslDetachArgs {
#[arg(long, value_name = "NAME")]
pub distribution: String,
}
#[derive(Debug, Args)]
pub struct WslInstallArgs {
#[arg(long, value_name = "NAME")]
pub distribution: String,
#[arg(long, value_name = "N")]
pub capacity: Option<u16>,
}
#[derive(Debug, Args)]
pub struct WslStatusArgs {
#[arg(long, value_name = "NAME")]
pub distribution: String,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Subcommand)]
pub enum WslHostCommand {
Hold,
}
#[derive(Debug, Subcommand)]
pub enum AuthCommand {
Login(AuthLoginArgs),
Status(AuthStatusArgs),
Logout,
#[command(hide = true)]
Receive(AuthReceiveArgs),
}
#[derive(Debug, Args)]
pub struct AuthLoginArgs {
#[arg(long, value_name = "WHEN")]
pub start_at: Option<StartAt>,
#[arg(long)]
pub list: bool,
}
#[derive(Debug, Args)]
pub struct AuthReceiveArgs {
#[arg(long, value_name = "WHEN")]
pub start_at: StartAt,
}
#[derive(Debug, Args)]
pub struct AuthStatusArgs {
#[arg(long)]
pub list: bool,
#[arg(long)]
pub permissions: bool,
}
#[derive(Debug, Subcommand)]
pub enum HostCommand {
SetCapacity(HostSetCapacityArgs),
SetRuntimeRoot(HostSetRuntimeRootArgs),
ResetRuntimeRoot,
Show,
}
#[derive(Debug, Args)]
pub struct HostSetCapacityArgs {
#[arg(value_name = "N")]
pub capacity: u16,
}
#[derive(Debug, Args)]
pub struct HostSetRuntimeRootArgs {
#[arg(long, value_name = "PATH", required = true)]
pub path: String,
}
#[derive(Debug, Args)]
pub struct StatusArgs {
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Args)]
pub struct UpdateArgs {
#[arg(long)]
pub check: bool,
}
#[derive(Debug, Subcommand)]
pub enum RepoCommand {
Add(RepoAddArgs),
List,
SetCapacity(RepoSetCapacityArgs),
SetScale(RepoSetScaleArgs),
AddLabel(RepoLabelArgs),
RemoveLabel(RepoLabelArgs),
SetWorkspace(RepoSetWorkspaceArgs),
Remove(RepoRemoveArgs),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum WorkspaceMode {
Ephemeral,
Persistent,
}
impl From<WorkspaceMode> for WorkspaceKind {
fn from(value: WorkspaceMode) -> Self {
match value {
WorkspaceMode::Ephemeral => WorkspaceKind::Ephemeral,
WorkspaceMode::Persistent => WorkspaceKind::Persistent,
}
}
}
#[derive(Debug, Args)]
pub struct RepoSetWorkspaceArgs {
#[arg(value_name = "OWNER/REPO")]
pub repository: String,
#[arg(long, value_name = "MODE")]
pub mode: WorkspaceMode,
#[arg(long, value_name = "PATH", required_if_eq("mode", "persistent"))]
pub path: Option<String>,
}
#[derive(Debug, Args)]
pub struct RepoAddArgs {
#[arg(value_name = "OWNER/REPO")]
pub repository: String,
#[arg(long, value_name = "HOST")]
pub host_label: String,
#[arg(long, value_name = "N")]
pub max_capacity: Option<u16>,
#[arg(long = "label", value_name = "LABEL")]
pub labels: Vec<String>,
#[arg(long)]
pub enable: bool,
}
#[derive(Debug, Args)]
pub struct RepoLabelArgs {
#[arg(value_name = "OWNER/REPO")]
pub repository: String,
#[arg(long = "label", value_name = "LABEL", required = true)]
pub labels: Vec<String>,
}
#[derive(Debug, Args)]
pub struct RepoSetCapacityArgs {
#[arg(value_name = "OWNER/REPO")]
pub repository: String,
#[arg(long, value_name = "N")]
pub max_capacity: u16,
}
#[derive(Debug, Args)]
pub struct RepoSetScaleArgs {
#[arg(value_name = "OWNER/REPO")]
pub repository: String,
#[arg(long, value_name = "BOOL", action = clap::ArgAction::Set)]
pub enabled: bool,
}
#[derive(Debug, Args)]
pub struct RepoRemoveArgs {
#[arg(value_name = "OWNER/REPO")]
pub repository: String,
#[arg(long)]
pub purge: bool,
}
#[derive(Debug, Subcommand)]
pub enum OrgCommand {
Add(OrgAddArgs),
List,
SetCapacity(OrgSetCapacityArgs),
SetScale(OrgSetScaleArgs),
AddLabel(OrgLabelArgs),
RemoveLabel(OrgLabelArgs),
Remove(OrgRemoveArgs),
}
#[derive(Debug, Args)]
pub struct OrgAddArgs {
#[arg(value_name = "ORG")]
pub organization: String,
#[arg(long, value_name = "HOST")]
pub host_label: String,
#[arg(long, value_name = "N")]
pub max_capacity: Option<u16>,
#[arg(long = "label", value_name = "LABEL")]
pub labels: Vec<String>,
#[arg(long)]
pub enable: bool,
}
#[derive(Debug, Args)]
pub struct OrgLabelArgs {
#[arg(value_name = "ORG")]
pub organization: String,
#[arg(long = "label", value_name = "LABEL", required = true)]
pub labels: Vec<String>,
}
#[derive(Debug, Args)]
pub struct OrgSetCapacityArgs {
#[arg(value_name = "ORG")]
pub organization: String,
#[arg(long, value_name = "N")]
pub max_capacity: u16,
}
#[derive(Debug, Args)]
pub struct OrgSetScaleArgs {
#[arg(value_name = "ORG")]
pub organization: String,
#[arg(long, value_name = "BOOL", action = clap::ArgAction::Set)]
pub enabled: bool,
}
#[derive(Debug, Args)]
pub struct OrgRemoveArgs {
#[arg(value_name = "ORG")]
pub organization: String,
#[arg(long)]
pub purge: bool,
}
#[derive(Debug, Subcommand)]
pub enum DaemonCommand {
Run(DaemonRunArgs),
}
#[derive(Debug, Args, Default)]
pub struct DaemonRunArgs {
#[arg(long, hide = true, requires_all = ["service_state_dir", "service_runtime_dir", "service_logs_dir"])]
pub service_config_dir: Option<PathBuf>,
#[arg(long, hide = true, requires_all = ["service_config_dir", "service_runtime_dir", "service_logs_dir"])]
pub service_state_dir: Option<PathBuf>,
#[arg(long, hide = true, requires_all = ["service_config_dir", "service_state_dir", "service_logs_dir"])]
pub service_runtime_dir: Option<PathBuf>,
#[arg(long, hide = true, requires_all = ["service_config_dir", "service_state_dir", "service_runtime_dir"])]
pub service_logs_dir: Option<PathBuf>,
#[arg(long, hide = true, requires = "service_config_dir")]
pub windows_service_host: bool,
}
impl DaemonRunArgs {
fn service_paths(&self) -> Option<AppPaths> {
Some(AppPaths::from_directories(
self.service_config_dir.as_ref()?,
self.service_state_dir.as_ref()?,
self.service_runtime_dir.as_ref()?,
self.service_logs_dir.as_ref()?,
))
}
}
#[derive(Debug, Subcommand)]
pub enum ServiceCommand {
Install(ServiceInstallArgs),
Uninstall,
Status,
}
#[derive(Debug, Args)]
pub struct ServiceInstallArgs {
#[arg(long, value_name = "WHEN", default_value = "boot")]
pub start_at: StartAt,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum StartAt {
Boot,
Login,
}
impl From<StartAt> for StartMode {
fn from(value: StartAt) -> Self {
match value {
StartAt::Boot => StartMode::Boot,
StartAt::Login => StartMode::Login,
}
}
}
#[derive(Debug)]
pub struct Context {
paths: AppPaths,
data_root: Option<PathBuf>,
endpoints: Endpoints,
clock: Arc<dyn Clock>,
#[cfg(test)]
secret_store_double: Option<Arc<dyn SecretStore>>,
}
impl Context {
pub fn resolve(data_dir: Option<&Path>, err: &mut dyn Write) -> Result<Self, CliError> {
let paths = match data_dir {
Some(root) => AppPaths::rooted_at(root),
None => AppPaths::discover().map_err(|source| {
CliError::with_remedy(
Failure::LocalState,
format!("cannot work out where this host's data directories live: {source}"),
"runner-manager --data-dir <DIR> <COMMAND>",
)
})?,
};
paths.create_all().map_err(|source| {
CliError::with_remedy(
Failure::LocalState,
format!("cannot create this host's data directories: {source}"),
"runner-manager --data-dir <DIR> <COMMAND>",
)
})?;
warn_about_an_app_override(err);
Ok(Self {
paths,
data_root: data_dir.map(Path::to_path_buf),
endpoints: Self::resolve_endpoints(err)?,
clock: Arc::new(SystemClock),
#[cfg(test)]
secret_store_double: None,
})
}
fn resolve_service(paths: AppPaths, err: &mut dyn Write) -> Result<Self, CliError> {
paths.create_all().map_err(|source| {
CliError::new(
Failure::LocalState,
format!("cannot create this service's application-data directories: {source}"),
)
})?;
Ok(Self {
paths,
data_root: None,
endpoints: Self::resolve_endpoints(err)?,
clock: Arc::new(SystemClock),
#[cfg(test)]
secret_store_double: None,
})
}
#[cfg(test)]
pub(crate) fn rooted_against(
paths_root: &Path,
endpoints: Endpoints,
) -> Result<Self, CliError> {
let paths = AppPaths::rooted_at(paths_root);
paths.create_all().map_err(|source| {
CliError::new(
Failure::LocalState,
format!("cannot create this test's data directories: {source}"),
)
})?;
Ok(Self {
paths,
data_root: Some(paths_root.to_path_buf()),
endpoints,
clock: Arc::new(SystemClock),
secret_store_double: None,
})
}
#[cfg(test)]
#[must_use]
pub(crate) fn with_secret_store(mut self, double: Arc<dyn SecretStore>) -> Self {
self.secret_store_double = Some(double);
self
}
fn resolve_endpoints(err: &mut dyn Write) -> Result<Endpoints, CliError> {
let Some(raw) = std::env::var_os(GITHUB_BASE_URL_VARIABLE) else {
return Ok(Endpoints::production());
};
let raw = raw.to_string_lossy().into_owned();
let endpoints = Endpoints::for_test_server(&raw).map_err(|source| {
CliError::new(
Failure::InvalidArgument,
format!("{GITHUB_BASE_URL_VARIABLE} is not usable as an endpoint base: {source}"),
)
})?;
refuse_unless_every_origin_is_loopback(&endpoints, &raw)?;
let _ = writeln!(
err,
"warning: talking to {raw} instead of GitHub, because \
{GITHUB_BASE_URL_VARIABLE} is set."
);
Ok(endpoints)
}
#[must_use]
pub fn paths(&self) -> &AppPaths {
&self.paths
}
#[must_use]
pub fn endpoints(&self) -> &Endpoints {
&self.endpoints
}
#[must_use]
pub fn clock(&self) -> Arc<dyn Clock> {
Arc::clone(&self.clock)
}
pub fn app_registration(&self) -> Result<AppRegistration, CliError> {
let against_a_fake_github = std::env::var_os(GITHUB_BASE_URL_VARIABLE).is_some();
let (client_id, slug) = if against_a_fake_github {
(
std::env::var(CLIENT_ID_VARIABLE)
.unwrap_or_else(|_| PUBLISHED_CLIENT_ID.to_string()),
std::env::var(APP_SLUG_VARIABLE).unwrap_or_else(|_| PUBLISHED_APP_SLUG.to_string()),
)
} else {
(
PUBLISHED_CLIENT_ID.to_string(),
PUBLISHED_APP_SLUG.to_string(),
)
};
AppRegistration::new(client_id, slug).map_err(|_| {
CliError::new(
Failure::AppNotPublished,
format!(
"this build carries no published GitHub App registration, so there is \
nothing to sign in to. Registering and publishing the App is Phase 0 of \
the rollout and has not happened yet, so {NO_OPERATOR_REMEDY}."
),
)
})
}
pub fn store(&self) -> Result<SqliteStore, CliError> {
let path = self.paths.config_dir().join(DATABASE_FILE);
SqliteStore::open(&path).map_err(|source| {
CliError::new(
Failure::LocalState,
format!(
"cannot open the local database at {}: {source}",
path.display()
),
)
})
}
pub fn secret_store(&self, start_mode: StartMode) -> Result<Arc<dyn SecretStore>, CliError> {
#[cfg(test)]
if let Some(double) = &self.secret_store_double {
return Ok(Arc::clone(double));
}
let scope = SecretScope::for_start_mode(start_mode);
let resolved = match &self.data_root {
Some(root) => PlatformSecretStore::rooted_at(scope, root),
None => PlatformSecretStore::standard(scope),
};
resolved
.map(|store| Arc::new(store) as Arc<dyn SecretStore>)
.map_err(|source| {
CliError::with_remedy(
Failure::SecretStore,
format!("cannot reach the {scope}-scoped secret store: {source}"),
"runner-manager host show",
)
})
}
pub fn recorded_start_mode(&self, store: &dyn Store) -> Result<StartMode, CliError> {
Ok(host::local_host(store)?.map_or_else(StartMode::default, |h| h.service_start_mode))
}
}
fn refuse_unless_every_origin_is_loopback(
endpoints: &Endpoints,
raw: &str,
) -> Result<(), CliError> {
let bases = [
("the API base", endpoints.api_base()),
(
"the web base, which is where the device flow hands over the token",
endpoints.web_base(),
),
];
for (what, base) in bases {
let host = base.host_str().unwrap_or_default();
if !is_loopback_host(host) {
return Err(CliError::new(
Failure::InvalidArgument,
format!(
"{GITHUB_BASE_URL_VARIABLE} may only point at a loopback address. \
{raw:?} put {what} on host {host:?}, which is not one. This variable \
redirects the device flow, and the device flow ends by handing a \
GitHub credential to whatever answered it."
),
));
}
}
Ok(())
}
fn is_loopback_host(host: &str) -> bool {
if host.eq_ignore_ascii_case("localhost") {
return true;
}
if let Ok(address) = host.parse::<std::net::Ipv4Addr>() {
return address.is_loopback();
}
let unbracketed = host.strip_prefix('[').and_then(|h| h.strip_suffix(']'));
if let Some(inner) = unbracketed
&& let Ok(address) = inner.parse::<std::net::Ipv6Addr>()
{
return address.is_loopback();
}
false
}
#[must_use]
pub fn dispatch() -> ExitCode {
let argv: Vec<std::ffi::OsString> = std::env::args_os().collect();
let cli = Cli::parse_from(&argv);
#[cfg(windows)]
if matches!(
&cli.command,
Command::Daemon(DaemonCommand::Run(args)) if args.windows_service_host
) {
return dispatch_windows_service(cli);
}
if let Some(distribution) = cli.host.distribution() {
return wsl::dispatch_to_selected_host(&cli, distribution, &argv);
}
if matches!(cli.command, Command::Tui) {
return crate::tui::run(cli.data_dir.as_deref());
}
let stdout = io::stdout();
let stderr = io::stderr();
let mut out = stdout.lock();
let mut err = stderr.lock();
match run(&cli, &mut out, &mut err) {
Ok(()) => {
let _ = out.flush();
ExitCode::SUCCESS
}
Err(failure) => {
let _ = out.flush();
let _ = failure.render(&mut err);
let _ = err.flush();
ExitCode::from(failure.class().code())
}
}
}
pub fn run(cli: &Cli, out: &mut dyn Write, err: &mut dyn Write) -> Result<(), CliError> {
run_with_shutdown(cli, out, err, None)
}
fn run_with_shutdown(
cli: &Cli,
out: &mut dyn Write,
err: &mut dyn Write,
service_shutdown: Option<runner_manager_platform::service::ServiceShutdown>,
) -> Result<(), CliError> {
let service_paths = match &cli.command {
Command::Daemon(DaemonCommand::Run(args)) => args.service_paths(),
_ => None,
};
if service_paths.is_some() && cli.data_dir.is_some() {
return Err(CliError::new(
Failure::InvalidArgument,
"the service supplied its recorded application-data directories, so --data-dir cannot also select a different database",
));
}
let role = if service_paths.is_some() {
runner_manager_platform::logging::LogRole::Service
} else {
runner_manager_platform::logging::LogRole::Operator
};
let context = match service_paths {
Some(paths) => Context::resolve_service(paths, err)?,
None => Context::resolve(cli.data_dir.as_deref(), err)?,
};
let _logging = match runner_manager_platform::logging::install(context.paths(), role, "warn") {
Ok(guard) => Some(guard),
Err(source) => {
let _ = writeln!(err, "warning: diagnostics are not being recorded: {source}");
None
}
};
if is_decorated_report(&cli.command) {
let mut buffered = Vec::new();
let outcome = route(
&cli.command,
&context,
&mut buffered,
service_shutdown,
Styling::plain_for_buffer(),
);
let text = String::from_utf8_lossy(&buffered);
ui::Ui::new(Styling::for_stdout())
.decorate(out, &text)
.map_err(write_failed("this report"))?;
return outcome;
}
route(
&cli.command,
&context,
out,
service_shutdown,
Styling::for_stdout(),
)
}
fn is_decorated_report(command: &Command) -> bool {
match command {
Command::Status(args) => !args.json,
Command::Wsl(WslCommand::Status(args)) => !args.json,
Command::Wsl(WslCommand::List | WslCommand::Detach(_)) => true,
Command::Host(_) | Command::Service(_) | Command::Repo(_) | Command::Org(_) => true,
Command::Auth(AuthCommand::Status(_) | AuthCommand::Logout) => true,
_ => false,
}
}
fn route(
command: &Command,
context: &Context,
out: &mut dyn Write,
service_shutdown: Option<runner_manager_platform::service::ServiceShutdown>,
styling: Styling,
) -> Result<(), CliError> {
match command {
Command::Auth(command) => auth::dispatch(context, command, styling, out),
Command::Host(command) => host::dispatch(context, command, styling, out),
Command::Status(args) => status::dispatch(context, args, out),
Command::Repo(command) => policy::dispatch_repo(context, command, out),
Command::Org(command) => policy::dispatch_org(context, command, out),
Command::Daemon(command) => daemon::dispatch(context, command, out, service_shutdown),
Command::Service(command) => service::dispatch(context, command, out),
Command::Update(args) => update::dispatch(context, args, out),
Command::Wsl(command) => wsl::dispatch(context, command, styling, out),
Command::WslHost(command) => wsl::dispatch_wsl_host(command, out),
Command::Tui => Err(not_implemented("g1")),
}
}
#[cfg(windows)]
fn dispatch_windows_service(cli: Cli) -> ExitCode {
match runner_manager_platform::service::run_windows_service_host(move |shutdown| {
let mut out = io::sink();
let mut err = io::sink();
match run_with_shutdown(&cli, &mut out, &mut err, Some(shutdown)) {
Ok(()) => 0,
Err(failure) => failure.class().code(),
}
}) {
Ok(0) => ExitCode::SUCCESS,
Ok(code) => ExitCode::from(code),
Err(failure) => {
let stderr = io::stderr();
let mut err = stderr.lock();
let cli_failure = CliError::new(Failure::LocalState, failure.to_string());
let _ = cli_failure.render(&mut err);
let _ = err.flush();
ExitCode::from(cli_failure.class().code())
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Styling {
enabled: bool,
}
impl Styling {
#[must_use]
pub fn for_stdout() -> Self {
Self {
enabled: std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none(),
}
}
#[must_use]
pub fn for_stderr() -> Self {
Self {
enabled: std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none(),
}
}
#[cfg(test)]
#[must_use]
pub const fn plain() -> Self {
Self { enabled: false }
}
fn wrap(self, codes: &str, text: &str) -> String {
if self.enabled {
format!("\u{1b}[{codes}m{text}\u{1b}[0m")
} else {
text.to_string()
}
}
#[must_use]
pub fn code(self, text: &str) -> String {
self.wrap("1;7;36", text)
}
#[must_use]
pub fn url(self, text: &str) -> String {
self.wrap("4;36", text)
}
#[must_use]
pub fn step(self, text: &str) -> String {
self.wrap("1;32", text)
}
#[must_use]
pub const fn plain_for_buffer() -> Self {
Self { enabled: false }
}
#[cfg(test)]
#[must_use]
pub const fn styled() -> Self {
Self { enabled: true }
}
#[must_use]
pub const fn is_enabled(self) -> bool {
self.enabled
}
#[must_use]
pub fn heading(self, text: &str) -> String {
self.wrap("1", text)
}
#[must_use]
pub fn rule(self, text: &str) -> String {
self.wrap("2", text)
}
#[must_use]
pub fn key(self, text: &str) -> String {
self.wrap("2;36", text)
}
#[must_use]
pub fn good(self, text: &str) -> String {
self.wrap("32", text)
}
#[must_use]
pub fn caution(self, text: &str) -> String {
self.wrap("33", text)
}
#[must_use]
pub fn failure(self, text: &str) -> String {
self.wrap("1;31", text)
}
#[must_use]
pub fn command(self, text: &str) -> String {
self.wrap("1;36", text)
}
}
pub(crate) fn open_in_browser(url: &str, styling: Styling) -> bool {
if !styling.enabled {
return false;
}
let mut command = if cfg!(target_os = "windows") {
let mut command = std::process::Command::new("cmd");
command.args(["/c", "start", "", url]);
command
} else if cfg!(target_os = "macos") {
let mut command = std::process::Command::new("open");
command.arg(url);
command
} else {
let mut command = std::process::Command::new("xdg-open");
command.arg(url);
command
};
command
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.is_ok()
}
fn warn_about_an_app_override(err: &mut dyn Write) {
let client_id = std::env::var(CLIENT_ID_VARIABLE).ok();
let slug = std::env::var(APP_SLUG_VARIABLE).ok();
let against_a_fake_github = std::env::var_os(GITHUB_BASE_URL_VARIABLE).is_some();
write_app_override_warning(
err,
client_id.as_deref(),
slug.as_deref(),
against_a_fake_github,
);
}
fn write_app_override_warning(
err: &mut dyn Write,
client_id: Option<&str>,
slug: Option<&str>,
against_a_fake_github: bool,
) {
if client_id.is_none() && slug.is_none() {
return;
}
let ui = ui::Ui::new(Styling::for_stderr());
if against_a_fake_github {
let named = slug.unwrap_or("<no slug set>");
let _ = ui.warning(
err,
&format!(
"authenticating as the GitHub App `{named}`, not this build's \
`{PUBLISHED_APP_SLUG}`, because {CLIENT_ID_VARIABLE} or {APP_SLUG_VARIABLE} is \
set alongside a {GITHUB_BASE_URL_VARIABLE} that is not GitHub."
),
);
return;
}
let _ = ui.warning(
err,
&format!(
"ignoring {CLIENT_ID_VARIABLE}/{APP_SLUG_VARIABLE}: they apply only when \
{GITHUB_BASE_URL_VARIABLE} points at a fake GitHub. Signing in as \
`{PUBLISHED_APP_SLUG}`. Unset them to silence this."
),
);
}
fn not_implemented(task: &str) -> CliError {
CliError::new(
Failure::NotImplemented,
format!(
"this command is declared but not implemented in this build (task {task}). \
It exits {} so a script can tell it apart from a usage error.",
Failure::NotImplemented.code()
),
)
}
pub fn runtime() -> Result<tokio::runtime::Runtime, CliError> {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|source| {
CliError::new(
Failure::Unclassified,
format!("cannot start the async runtime: {source}"),
)
})
}
pub fn create_local_host(store: &dyn Store, clock: &dyn Clock) -> Result<Host, CliError> {
let support = runner_manager_platform::os::detect().map_err(|source| {
CliError::new(
Failure::UnsupportedHost,
format!("this machine cannot run GitHub's runner application: {source}"),
)
})?;
let capacity = std::num::NonZeroU16::new(DEFAULT_HOST_CAPACITY)
.expect("DEFAULT_HOST_CAPACITY is a non-zero constant");
let host = Host::new(
runner_manager_domain::model::HostId::new_random(),
local_display_name(),
support.os(),
support.arch(),
capacity,
clock.now(),
)
.map_err(|source| {
CliError::new(
Failure::LocalState,
format!("cannot describe this host: {source}"),
)
})?;
store.put_host(&host).map_err(|source| {
CliError::new(
Failure::LocalState,
format!("cannot record this host: {source}"),
)
})?;
Ok(host)
}
fn local_display_name() -> String {
for variable in ["COMPUTERNAME", "HOSTNAME"] {
if let Ok(value) = std::env::var(variable) {
let trimmed = value.trim();
if !trimmed.is_empty() {
return trimmed.to_string();
}
}
}
"this host".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory as _;
#[test]
fn the_exit_codes_are_distinct_and_non_zero() {
let mut seen = std::collections::BTreeMap::new();
for class in Failure::ALL.iter().copied() {
assert_ne!(
class.code(),
0,
"{class} would be indistinguishable from success"
);
assert_ne!(
class.code(),
2,
"{class} would be indistinguishable from clap's usage error, which exits 2 \
before any of this code runs"
);
if let Some(previous) = seen.insert(class.code(), class) {
panic!("{previous} and {class} both exit {}", class.code());
}
}
assert_eq!(
seen.len(),
Failure::ALL.len(),
"every class must occupy its own code"
);
}
#[test]
fn every_class_is_reachable_from_all_and_names_itself_uniquely() {
assert!(
Failure::ALL.len() >= 19,
"the taxonomy has only ever grown; a shorter `ALL` means classes were \
removed without the scripting contract being revisited"
);
let mut names = std::collections::BTreeMap::new();
for class in Failure::ALL.iter().copied() {
assert!(
!class.as_str().is_empty(),
"{class:?} has no stable name for a `--json` document to carry"
);
if let Some(previous) = names.insert(class.as_str(), class) {
panic!(
"{previous:?} and {class:?} are both called {:?}",
class.as_str()
);
}
}
assert_eq!(names.len(), Failure::ALL.len());
}
#[derive(Debug)]
struct BrokenPipe;
impl Write for BrokenPipe {
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
Err(io::Error::new(io::ErrorKind::BrokenPipe, "the pipe closed"))
}
fn flush(&mut self) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::BrokenPipe, "the pipe closed"))
}
}
#[test]
fn every_command_names_the_operation_whose_output_failed() {
let temporary = tempfile::tempdir().expect("a temporary directory");
let mut discarded = Vec::new();
let context = Context::resolve(Some(temporary.path()), &mut discarded)
.expect("a context rooted at a temporary directory");
let expected: [(&str, &str); 6] = [
("auth login", "this sign-in"),
("auth status", "this credential's status"),
("auth logout", "this sign-out"),
("host set-capacity", "this host's new capacity"),
("host show", "this host's settings"),
("status", "this host's status"),
];
let run_one = |command: &str| -> CliError {
let out: &mut dyn Write = &mut BrokenPipe;
let outcome = match command {
"auth login" => auth::login(&context, None, false, Styling::plain(), out),
"auth status" => auth::status(
&context,
&AuthStatusArgs {
list: false,
permissions: false,
},
Styling::plain(),
out,
),
"auth logout" => auth::logout(&context, out),
"host set-capacity" => {
host::set_capacity(&context, &HostSetCapacityArgs { capacity: 1 }, out)
}
"host show" => host::show(&context, out),
"status" => status::dispatch(&context, &StatusArgs { json: false }, out),
other => panic!("unknown command {other}"),
};
outcome.expect_err("a sink that fails on the first byte must fail the command")
};
for (command, noun) in expected {
let error = run_one(command);
assert_eq!(
error.class(),
Failure::Unclassified,
"`{command}` must report a write failure as a write failure, not as \
something about GitHub or the local database: {error}"
);
assert!(
error.message().contains(noun),
"`{command}` must say it could not write {noun:?}; it said: {error}"
);
for (other_command, other_noun) in expected {
if other_noun == noun {
continue;
}
assert!(
!error.message().contains(other_noun),
"`{command}` reported {other_noun:?}, which belongs to \
`{other_command}`: {error}"
);
}
}
}
#[test]
fn the_command_tree_is_well_formed() {
Cli::command().debug_assert();
}
#[test]
fn repository_and_organization_set_scale_parse_explicit_true_and_false() {
for (scope, target) in [("repo", "octo/repo"), ("org", "octo")] {
for expected in [true, false] {
let cli = Cli::try_parse_from([
"runner-manager",
scope,
"set-scale",
target,
"--enabled",
if expected { "true" } else { "false" },
])
.unwrap();
let actual = match cli.command {
Command::Repo(RepoCommand::SetScale(args)) => args.enabled,
Command::Org(OrgCommand::SetScale(args)) => args.enabled,
_ => panic!("wrong command parsed for {scope}"),
};
assert_eq!(actual, expected, "{scope} must retain explicit {expected}");
}
}
}
#[test]
fn only_a_loopback_origin_may_replace_github() {
for raw in [
"http://127.0.0.1:8080/",
"http://127.0.0.2/",
"http://[::1]:8080/",
"http://localhost:8080/",
"http://LOCALHOST:8080/",
] {
let endpoints = Endpoints::for_test_server(raw).expect("a valid URL");
refuse_unless_every_origin_is_loopback(&endpoints, raw)
.unwrap_or_else(|error| panic!("{raw} must be accepted: {error}"));
}
for raw in [
"https://api.github.com/",
"http://127.0.0.1.evil.example/",
"http://localhost.evil.example/",
"http://10.0.0.1/",
"http://[2001:db8::1]/",
] {
let endpoints = Endpoints::for_test_server(raw).expect("a valid URL");
assert!(
refuse_unless_every_origin_is_loopback(&endpoints, raw).is_err(),
"{raw} must be refused: this variable redirects the device flow, and the \
device flow ends by handing a GitHub credential to whatever answered"
);
}
assert!(!is_loopback_host(""), "an absent host is not loopback");
}
#[test]
fn a_pair_whose_web_base_is_remote_is_refused_even_when_the_api_base_is_loopback() {
let loopback = Endpoints::for_test_server("http://127.0.0.1:8080/").expect("valid");
let production = Endpoints::production();
let split = Endpoints::new(loopback.api_base().clone(), production.web_base().clone());
assert!(
is_loopback_host(split.api_base().host_str().unwrap_or_default()),
"the API half of this pair is loopback, which is what makes it the case the \
old check would have waved through"
);
let refusal = refuse_unless_every_origin_is_loopback(&split, "http://127.0.0.1:8080/")
.expect_err(
"a pair whose web base is github.com must be refused: `access_token_url` \
joins `web_base`, so that is the origin the bearer token is handed to",
);
assert!(
refusal.message().contains("hands over the token"),
"the refusal must name which base failed: {refusal}"
);
let both_loopback =
Endpoints::new(loopback.api_base().clone(), loopback.web_base().clone());
refuse_unless_every_origin_is_loopback(&both_loopback, "http://127.0.0.1:8080/")
.expect("a pair that is loopback on both bases must be accepted");
}
#[test]
fn no_plausible_client_id_is_compiled_in() {
assert!(
PUBLISHED_CLIENT_ID.len() == 20
&& PUBLISHED_CLIENT_ID.starts_with("Iv")
&& PUBLISHED_CLIENT_ID
.chars()
.all(|character| character.is_ascii_alphanumeric()),
"`{PUBLISHED_CLIENT_ID}` is not shaped like a GitHub device-flow client id"
);
assert!(
!PUBLISHED_APP_SLUG.is_empty()
&& PUBLISHED_APP_SLUG
.chars()
.all(|character| character.is_ascii_lowercase()
|| character.is_ascii_digit()
|| character == '-'),
"`{PUBLISHED_APP_SLUG}` is not a GitHub App slug, and it is what \
`github.com/apps/<slug>/installations/new` is built from"
);
}
#[test]
fn a_failure_renders_its_remedy() {
let error = CliError::with_remedy(
Failure::NotAuthenticated,
"no credential is stored on this host",
"runner-manager auth login",
);
let mut rendered = Vec::new();
error.render(&mut rendered).expect("writing to a Vec");
let rendered = String::from_utf8(rendered).expect("ASCII");
assert!(rendered.contains("error: no credential is stored on this host"));
assert!(rendered.contains("try: runner-manager auth login"));
}
#[test]
fn plain_styling_emits_no_escape_sequences() {
let plain = Styling::plain();
for rendered in [
plain.code("WDJB-MJHT"),
plain.url("https://github.com/login/device"),
plain.step("Action 2 of 3:"),
] {
assert!(
!rendered.contains('\u{1b}'),
"plain styling wrote an escape sequence: {rendered:?}"
);
}
assert_eq!(plain.code("WDJB-MJHT"), "WDJB-MJHT");
}
#[test]
fn styled_output_wraps_the_text_and_resets_afterwards() {
let styled = Styling::styled();
let rendered = styled.code("WDJB-MJHT");
assert!(rendered.contains("WDJB-MJHT"));
assert!(rendered.starts_with('\u{1b}'), "not styled: {rendered:?}");
assert!(
rendered.ends_with("\u{1b}[0m"),
"styling must reset: {rendered:?}"
);
}
#[test]
fn an_app_override_is_announced_and_names_the_published_slug() {
let mut in_force = Vec::new();
write_app_override_warning(
&mut in_force,
Some("Iv23li39jMQVdEuupmI2"),
Some("runner-manager-d17-spike"),
true,
);
let rendered = String::from_utf8(in_force).expect("ASCII");
assert!(
rendered.contains("runner-manager-d17-spike") && rendered.contains(PUBLISHED_APP_SLUG),
"the warning must name BOTH the App being used and the one this build \
publishes, or it does not tell the operator what is wrong: {rendered}"
);
let mut ignored = Vec::new();
write_app_override_warning(
&mut ignored,
Some("Iv23li39jMQVdEuupmI2"),
Some("runner-manager-d17-spike"),
false,
);
let rendered = String::from_utf8(ignored).expect("ASCII");
assert!(
rendered.contains("ignoring") && rendered.contains(PUBLISHED_APP_SLUG),
"against real GitHub the warning must say the override is ignored and \
name the App actually used: {rendered}"
);
assert!(
rendered.contains(CLIENT_ID_VARIABLE) || rendered.contains(APP_SLUG_VARIABLE),
"and it must name the variable to unset: {rendered}"
);
let mut quiet = Vec::new();
write_app_override_warning(&mut quiet, None, None, false);
assert!(
quiet.is_empty(),
"a build with no override must say nothing: {:?}",
String::from_utf8_lossy(&quiet)
);
}
}