Documentation
¶
Overview ¶
ars_types.go — minimal AutoscalingRunnerSet CRD.
The controller-side tenant catalog (crTenantSource) lists AutoscalingRunnerSet custom resources to discover which scale sets exist. Arc's full apis/actions.github.com/v1alpha1 type embeds a corev1.PodTemplateSpec (the entire k8s core API) plus proxy, TLS, vault, and listener-metadata sub-objects — far too large to copy cleanly, and none of it is read by the tenant catalog.
This is the minimal projection: TypeMeta + ObjectMeta + the six Spec fields crTenantSource / arsToTenant actually read, with hand-written DeepCopy and scheme registration so it satisfies client.Object and works with a controller-runtime client (real or fake). The GroupKind ("actions.github.com/v1alpha1, AutoscalingRunnerSet") matches arc so a client points at the same CRs.
Token provider: GitHub App installation tokens (preferred) with PAT fallback. Tokens are minted on demand and cached until 60s before expiry. One TokenProvider serves all orgs; per-org installation lookups are cached so we never re-call /orgs/<org>/installation on the hot path.
Package runner is the host-role JIT runner daemon: a bare-metal workstation agent that polls GitHub for queued workflow jobs whose labels are a subset of this host's labels, mints just-in-time runner configs via the GitHub Actions API, and spawns actions-runner with --jitconfig. Each spawned runner picks one job, exits, and auto-deregisters server-side.
This is the host role only — the standalone daemon that runs on a developer box or bare-metal fleet node. The in-cluster controller is a separate concern and is not part of this package.
Config for the JIT runner daemon. One YAML per host, loaded once at startup, validated, then immutable for the process lifetime.
credentials.go — local credential map keyed by RunnerScaleSetName.
A host learns its tenant list from the controller via the tenant catalog (tenantsource.go). Identity (configure URL, scale set name, ERS pointer, min/max runners) flows over the wire. Credentials NEVER cross the wire — they are loaded locally from either:
- A legacy MultiConfig (supplies GitHub App / PAT auth flattened on each Tenants[i] entry), OR
- A flat credentials JSON at ARC_CREDENTIALS that maps runner_scale_set_name → AppConfig.
Both sources are merged. ARC_CREDENTIALS wins on conflict — the flat map is the forward-compatible form. A tenant whose remote identity has no local credential entry is SKIPPED with a structured log line, and its name is returned to the caller for ops dashboards.
The polling daemon. One JITDaemon owns:
- the config
- one TokenProvider
- one ghClient
- one runnerLauncher
On every tick: fan out across orgs (bounded by Parallelism), list repos, list queued jobs per repo, for each match (label subset) mint a JIT config and spawn a runner. Each spawned runner runs in its own goroutine; a per-job dedup map prevents double-spawning while another runner picks it up. A global MaxConcurrentRunners semaphore (when set) caps how many runner subprocesses run at once across all orgs.
GitHub REST client adapted to the JIT daemon's needs: enumerate queued jobs across an org's repos, mint JIT runner configs. Direct HTTP (one stdlib client) keeps deps minimal and allows per-request token selection.
Host role: the JIT runner daemon for bare-metal workstations.
Polls every org listed in the config for queued workflow jobs whose labels are a subset of this host's labels. On match, mints a just-in-time runner config via the GitHub Actions API and spawns actions-runner with --jitconfig. The runner picks the one job, exits, and auto-deregisters server-side.
In addition to the JIT loop, when a control channel is configured (Config.Dialer + Config.ControlPlaneAddr, injected by the CLI) the host opens a session to the in-cluster controller and heartbeats. Tenant configuration delivered over that channel is intended to take precedence over the local YAML file — the control plane is the new path and the local file is the offline fallback. When no control channel is configured (the default), the host runs fully standalone: only the local YAML config drives tenant selection.
/v1 HTTP surface consumed by arcd-tray and any other local UI.
Endpoints bind to the same listener as /healthz (HealthAddr from Config), which defaults to 127.0.0.1:7777 — reachability from THIS machine is the auth model. localGuard makes that model hold against the web even on loopback: a Host allowlist defeats DNS-rebinding and an Origin check defeats CSRF, so a website the operator visits cannot pause/resume the runner or read its job list. A real local client (the CLI/tray) sends no Origin and passes. Binding HealthAddr to 0.0.0.0 re-exposes the surface to the LAN — don't.
Runner subprocess management. Each match (queued job whose labels fit this host) gets one runnerLauncher.Run() call: mint JIT config, exec the actions-runner binary with --jitconfig, wait for exit. Runner picks the one job, exits, auto-deregisters server-side.
Daemon state shared between the JIT loop and the tray HTTP surface. Kept separate so the tray UI's observability surface doesn't grow new fields on JITDaemon for every menu item we add later.
tenantconfig.go — the per-tenant runtime config and the multi-tenant bag the controller-side tenant catalog and credential merge operate on.
These are minimal projections of arc's cmd/ghalistener/config.Config (one tenant) and cmd/multighalistener/config.MultiConfig (the bag). The upstream single-tenant config also carries Vault / Azure Key Vault / metrics / proxy / scaleset-client machinery that the host role's tenant catalog and credential merge never touch — only the identity + sizing + credential fields below are needed, so only those are copied.
tenantsource.go — controller-side tenant catalog.
The tenant catalog answers "what tenants exist" and is served to hosts over the control channel. It is deliberately separated from the credential bag (credentials.go): identity is a fact about the cluster, credentials are a fact about each operator's secrets.
tenantSource abstracts the catalog. Three implementations:
mcTenantSource: serves the static MultiConfig the controller was booted with. Useful as a fallback when the controller-runtime client isn't configured.
crTenantSource: lists AutoscalingRunnerSet CRs from the cluster using a non-cached controller-runtime client. The CRs are the authoritative catalog. This is in-cluster-only: on a host with no cluster (the standalone JIT daemon) it is never constructed.
combinedTenantSource: try CR first, fall back to MultiConfig.
transport.go — the OPTIONAL controller<->host control channel.
The host role is complete without a control channel: it drives tenant selection from the local YAML config (cfg.Orgs) and mints JIT runners directly against GitHub. That is the documented offline / local mode.
When a control plane is available, the host additionally opens a session to the in-cluster arcd singleton to heartbeat and pull the tenant set. arcd's concrete implementation is ZAP-on-QUIC with an X25519MLKEM768 hybrid PQ-KEM mTLS wire — but this package does not depend on it. The host only needs the small surface below; the concrete Dialer (and all of its cert/KMS machinery) is injected by the cloud CLI. Default is nil: no dialer, YAML-only mode.
WSL2 detection for arcd.
arcd inside WSL2 is a Linux process by build target (GOOS=linux) but the host kernel and Windows side are reachable through interop. Two things matter to the daemon:
Service install uses systemd-user, NOT the Windows SCM. The Windows SCM is unreachable from inside the WSL Linux namespace, and even if it were reachable, registering a Windows service that runs arcd-linux under wsl.exe is brittle. systemd-user works exactly like a native Linux box (modulo systemd-genie-ish caveats — see svcInstall fallback).
JIT label routing must distinguish a WSL2 host from native Linux. The same physical box (e.g. evo) can run BOTH a native Windows arcd (windows/amd64, labels include `windows`) AND a WSL2 arcd (linux/amd64, labels include `wsl`). Workflows targeting `runs-on: [self-hosted, evo, windows, x64, hip]` must land on the Windows daemon; `runs-on: [self-hosted, evo, linux, x64, wsl, hip-rocm-wsl]` must land on the WSL2 daemon. The two daemons coexist because their label sets are distinct.
Detection signals (any one is sufficient — we union them for robustness):
- $WSL_DISTRO_NAME set in env (set automatically inside any WSL distro)
- /proc/sys/kernel/osrelease contains "WSL" or "microsoft" (case-insens)
- /proc/version contains "microsoft" (case-insensitive)
On non-Linux GOOS this file compiles to no-ops via the build tag below.
Index ¶
- Variables
- func AugmentLabelsWithWSL(labels []string) []string
- func IsWSL() bool
- func RunHost(ctx context.Context, cfg Config) error
- type AppConfig
- type AutoscalingRunnerSet
- type AutoscalingRunnerSetList
- type AutoscalingRunnerSetSpec
- type Config
- type ControlChannel
- type Dialer
- type JITDaemon
- type JITRunnerConfig
- type JobRecord
- type MultiConfig
- type Repo
- type Tenant
- type TenantConfig
- type TokenProvider
- type WorkflowJob
- type WorkflowRun
Constants ¶
This section is empty.
Variables ¶
var ( // GroupVersion is the group-version these objects register under. GroupVersion = schema.GroupVersion{Group: "actions.github.com", Version: "v1alpha1"} // SchemeBuilder registers the CRD types with a runtime.Scheme. SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} // AddToScheme adds the CRD types in this group-version to a scheme. AddToScheme = SchemeBuilder.AddToScheme )
var ( Version = "dev" Commit = "" Date = "" )
Build metadata, stamped at link time by the cloud CLI via -ldflags (e.g. -X github.com/hanzoai/cloud/runner.Version=1.2.3). Defaults keep `go test` and `go run` builds honest about being untagged dev builds.
Functions ¶
func AugmentLabelsWithWSL ¶
AugmentLabelsWithWSL appends "wsl" to the host's effective labels when running inside WSL2. Caller passes the labels parsed from config.yaml and receives them back with the marker added if appropriate. No-op on native Linux, macOS, or Windows.
Idempotent: if "wsl" is already present (case-insensitive), nothing is added. The caller's slice order is preserved; the new marker, if any, is appended at the end.
func IsWSL ¶
func IsWSL() bool
IsWSL reports whether the current process is running inside a WSL2 distro.
Lazy: we read /proc twice the first time, then cache nothing — the result can't change for a process's lifetime, but each call is two os.ReadFile of ~30 bytes total, so caching would be premature.
func RunHost ¶
RunHost runs the host-role JIT daemon with the given config until ctx is cancelled. It mirrors arcd's runOnHost: the JIT poll loop always runs; the control channel runs alongside it only when cfg carries a Dialer and a ControlPlaneAddr. Failure to reach the control plane never stops the local JIT loop — YAML-only mode is a supported offline fallback.
Types ¶
type AppConfig ¶
type AppConfig struct {
AppID string `json:"github_app_id"`
AppInstallationID int64 `json:"github_app_installation_id"`
AppPrivateKey string `json:"github_app_private_key"`
Token string `json:"github_token"`
}
AppConfig is the GitHub credential bag for one tenant: either a PAT (Token) or a GitHub App triple (AppID + AppInstallationID + AppPrivateKey), never both.
This is the minimal projection of arc's apis/actions.github.com/v1alpha1/appconfig.AppConfig — only the four fields and the Validate method the host-role credential merge uses. The full upstream type also carries Secret/JSON constructors that depend on k8s.io/api; those are not needed here.
type AutoscalingRunnerSet ¶
type AutoscalingRunnerSet struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec AutoscalingRunnerSetSpec `json:"spec,omitempty"`
}
AutoscalingRunnerSet is the schema for the autoscalingrunnersets API. Only the identity + sizing fields the tenant catalog reads are modeled; the upstream Spec/Status carry much more.
func (*AutoscalingRunnerSet) DeepCopy ¶
func (in *AutoscalingRunnerSet) DeepCopy() *AutoscalingRunnerSet
DeepCopy returns a deep copy of the object.
func (*AutoscalingRunnerSet) DeepCopyInto ¶
func (in *AutoscalingRunnerSet) DeepCopyInto(out *AutoscalingRunnerSet)
DeepCopyInto copies the receiver into out.
func (*AutoscalingRunnerSet) DeepCopyObject ¶
func (in *AutoscalingRunnerSet) DeepCopyObject() runtime.Object
DeepCopyObject satisfies runtime.Object.
type AutoscalingRunnerSetList ¶
type AutoscalingRunnerSetList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []AutoscalingRunnerSet `json:"items"`
}
AutoscalingRunnerSetList is a list of AutoscalingRunnerSet.
func (*AutoscalingRunnerSetList) DeepCopy ¶
func (in *AutoscalingRunnerSetList) DeepCopy() *AutoscalingRunnerSetList
DeepCopy returns a deep copy of the list.
func (*AutoscalingRunnerSetList) DeepCopyInto ¶
func (in *AutoscalingRunnerSetList) DeepCopyInto(out *AutoscalingRunnerSetList)
DeepCopyInto copies the receiver into out.
func (*AutoscalingRunnerSetList) DeepCopyObject ¶
func (in *AutoscalingRunnerSetList) DeepCopyObject() runtime.Object
DeepCopyObject satisfies runtime.Object.
type AutoscalingRunnerSetSpec ¶
type AutoscalingRunnerSetSpec struct {
GitHubConfigUrl string `json:"githubConfigUrl,omitempty"`
RunnerGroup string `json:"runnerGroup,omitempty"`
RunnerScaleSetName string `json:"runnerScaleSetName,omitempty"`
RunnerScaleSetLabels []string `json:"runnerScaleSetLabels,omitempty"`
MaxRunners *int `json:"maxRunners,omitempty"`
MinRunners *int `json:"minRunners,omitempty"`
}
AutoscalingRunnerSetSpec is the minimal desired state read by the tenant catalog.
func (*AutoscalingRunnerSetSpec) DeepCopy ¶
func (in *AutoscalingRunnerSetSpec) DeepCopy() *AutoscalingRunnerSetSpec
DeepCopy returns a deep copy of the spec.
func (*AutoscalingRunnerSetSpec) DeepCopyInto ¶
func (in *AutoscalingRunnerSetSpec) DeepCopyInto(out *AutoscalingRunnerSetSpec)
DeepCopyInto copies the receiver into out.
type Config ¶
type Config struct {
HostName string `yaml:"host_name"`
Labels []string `yaml:"labels"`
RunnerDir string `yaml:"runner_dir"`
WorkDir string `yaml:"work_dir"`
PollInterval time.Duration `yaml:"poll_interval"`
Parallelism int `yaml:"parallelism"`
HealthAddr string `yaml:"health_addr"`
AppID int64 `yaml:"app_id"`
AppKeyPath string `yaml:"app_private_key_path"`
PATFile string `yaml:"pat_file"`
Orgs []string `yaml:"orgs"`
// AllowForks lets the daemon serve jobs from FORK repositories the org owns.
// Default false skips them as defense-in-depth. This is NOT the primary
// fork-PR protection (see daemon.go): a self-hosted runner runs workflow code
// as this OS user and can read the on-disk App key/PAT, so the operational
// rule is to point the daemon at PRIVATE/INTERNAL orgs only. Enable only for
// an org whose forks you fully trust.
AllowForks bool `yaml:"allow_forks"`
RunnerBinary string `yaml:"runner_binary"` // optional, defaults to runner_dir/run.sh
RunnerVersion string `yaml:"runner_version"` // optional, sanity log only
GitHubAPIBase string `yaml:"github_api_base"` // optional, defaults to https://api.github.com
// MaxConcurrentRunners caps the number of actions-runner subprocesses
// this host runs at once, across all orgs/repos. Parallelism bounds
// the org-scan fan-out (how fast we discover work); this bounds the
// spawn (how much work runs at once) so a burst of queued jobs can't
// fork-bomb the box. Default 0 means unlimited (legacy behavior).
MaxConcurrentRunners int `yaml:"max_concurrent_runners"`
// RepoListTTL bounds how often the daemon re-fetches an org's repo
// list. The repo set changes rarely (new repo, archive, transfer) so
// re-listing on every poll cycle is wasted budget — for orgs with
// hundreds of repos the paginated /orgs/{org}/repos calls dominate
// the per-cycle API budget and starve the actually-useful
// /repos/.../actions/runs?status=queued polls. Default is 1 hour.
// Set to 0 to disable caching (re-fetch every tick).
RepoListTTL time.Duration `yaml:"repo_list_ttl"`
// ControlPlaneAddr and Dialer are the OPTIONAL control channel to the
// in-cluster controller. They are set programmatically by the CLI,
// never from YAML — a host with neither runs standalone on the local
// config, which is the documented offline fallback. See transport.go.
ControlPlaneAddr string `yaml:"-"`
Dialer Dialer `yaml:"-"`
}
Config is the host-role daemon configuration, loaded from a single YAML file (see LoadConfig). It is the exported entrypoint config the cloud CLI passes to RunHost.
func LoadConfig ¶
LoadConfig reads and validates the host config from path. An empty path defaults to ~/.arcd/config.yaml.
type ControlChannel ¶
type ControlChannel interface {
// GetTenants pulls the set of tenants this box should be serving.
GetTenants(ctx context.Context, box string, labels []string, arch, goos string) ([]Tenant, error)
// Heartbeat reports the box's liveness to the controller.
Heartbeat(ctx context.Context, box string, lastReconcileUnixSec int64, version string) error
// Close releases the underlying connection.
Close() error
}
ControlChannel is everything the host role needs from a control-plane transport. It is intentionally tiny — heartbeat + tenant pull + close. A nil ControlChannel means "no control plane": the host runs fully on its local YAML config, which is the supported offline fallback.
type Dialer ¶
Dialer opens a ControlChannel to the given control-plane address for the given host config. Returning (nil, nil) is legal and means "no channel available"; the host logs the fallback and stays in YAML-only mode. The concrete arctransport dialer — cert material from KMS, the ZAP-on-QUIC client — lives in the cloud CLI and is assigned to Config.Dialer there. This package ships no default dialer, so the host is standalone unless one is injected.
type JITRunnerConfig ¶
type JobRecord ¶
type JobRecord struct {
Org string `json:"org"`
Repo string `json:"repo"`
WorkflowName string `json:"workflow"`
JobName string `json:"job"`
JobID int64 `json:"job_id"`
RunnerName string `json:"runner_name"`
HTMLURL string `json:"html_url"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at,omitempty"`
Status string `json:"status"` // running | done | failed
}
JobRecord is one observed unit of work — either an active runner subprocess or a recently-finished one. The tray surfaces both in a single chronological list, distinguishing them by Status.
type MultiConfig ¶
type MultiConfig struct {
// Tenants is the list of per-org/per-scale-set configurations.
Tenants []*TenantConfig `json:"tenants"`
}
MultiConfig is the multi-tenant bag: N TenantConfig triples the controller was booted with. It is the static fallback catalog served by mcTenantSource when no cluster CRs are available.
type Tenant ¶
type Tenant struct {
Name string
Org string
RunnerLabels []string
RunnerGroup string
Arch string
RunnerScaleSetID uint32
MaxRunners uint32
ConfigureURL string
RunnerScaleSetName string
EphemeralRunnerSetName string
EphemeralRunnerSetNamespace string
MinRunners uint32
}
Tenant is the wire identity of one runner scale set the controller tells a host to serve. Credentials never appear here — they are loaded locally, keyed by RunnerScaleSetName (see credentials.go).
This is a verbatim copy of arc's arctransport.Tenant so the tenant catalog (tenantsource.go) and credential merge (credentials.go) port without depending on the archived arctransport package.
type TenantConfig ¶
type TenantConfig struct {
ConfigureURL string `json:"configure_url"`
// AppConfig contains the GitHub credentials. Loaded locally, never
// over the wire.
*AppConfig
EphemeralRunnerSetNamespace string `json:"ephemeral_runner_set_namespace"`
EphemeralRunnerSetName string `json:"ephemeral_runner_set_name"`
MaxRunners int `json:"max_runners"`
MinRunners int `json:"min_runners"`
RunnerScaleSetID int `json:"runner_scale_set_id"`
RunnerScaleSetName string `json:"runner_scale_set_name"`
}
TenantConfig is one tenant's runtime configuration: GitHub identity, scale-set naming, min/max sizing, and (locally-sourced) credentials. AppConfig is embedded so a TenantConfig carries its credential bag the same way the upstream listener config does.
type TokenProvider ¶
type TokenProvider struct {
// contains filtered or unexported fields
}
func NewTokenProvider ¶
func NewTokenProvider(cfg *Config) (*TokenProvider, error)
func (*TokenProvider) RotatePAT ¶
func (tp *TokenProvider) RotatePAT()
RotatePAT advances to the next PAT in the list. Call when a request returns 401/403 to try the next token.