runner

package
v1.801.459 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 5, 2026 License: Apache-2.0 Imports: 33 Imported by: 0

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:

  1. A legacy MultiConfig (supplies GitHub App / PAT auth flattened on each Tenants[i] entry), OR
  2. 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:

  1. 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).

  2. 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

Constants

This section is empty.

Variables

View Source
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
)
View Source
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

func AugmentLabelsWithWSL(labels []string) []string

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

func RunHost(ctx context.Context, cfg Config) error

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.

func (*AppConfig) Validate

func (c *AppConfig) Validate() error

Validate rejects a credential bag that is empty or that ambiguously carries both a PAT and GitHub App credentials.

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

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

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

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

func LoadConfig(path string) (Config, error)

LoadConfig reads and validates the host config from path. An empty path defaults to ~/.arcd/config.yaml.

func (*Config) HasLabels

func (c *Config) HasLabels(required []string) bool

HasLabels returns true if the given set of required labels is a subset of this host's labels (case-insensitive). GitHub does the same set matching when dispatching jobs.

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

type Dialer func(ctx context.Context, addr string, cfg *Config) (ControlChannel, error)

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 JITDaemon

type JITDaemon struct {
	// contains filtered or unexported fields
}

func NewJITDaemon

func NewJITDaemon(cfg *Config, logger *slog.Logger) (*JITDaemon, error)

func (*JITDaemon) Run

func (d *JITDaemon) Run(ctx context.Context) error

type JITRunnerConfig

type JITRunnerConfig struct {
	EncodedJITConfig string `json:"encoded_jit_config"`
	Runner           struct {
		ID   int64  `json:"id"`
		Name string `json:"name"`
	} `json:"runner"`
}

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 Repo

type Repo struct {
	Owner    string `json:"-"`
	Name     string `json:"name"`
	FullName string `json:"full_name"`
	Disabled bool   `json:"disabled"`
	Archived bool   `json:"archived"`
	Fork     bool   `json:"fork"`
}

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.

func (*TokenProvider) Token

func (tp *TokenProvider) Token(ctx context.Context, org string) (string, error)

Token returns a token usable as `Authorization: Bearer <token>` for requests scoped to the given org. App installation tokens are cached until 60s before expiry; PATs are returned unmodified (no expiry tracking — admin rotates PAT file).

type WorkflowJob

type WorkflowJob struct {
	ID           int64    `json:"id"`
	RunID        int64    `json:"run_id"`
	Name         string   `json:"name"`
	Status       string   `json:"status"`
	Labels       []string `json:"labels"`
	HTMLURL      string   `json:"html_url"`
	WorkflowName string   `json:"workflow_name"`
}

type WorkflowRun

type WorkflowRun struct {
	ID      int64  `json:"id"`
	Status  string `json:"status"`
	HeadSHA string `json:"head_sha"`
	Name    string `json:"name"`
	HTMLURL string `json:"html_url"`
	Repo    *Repo  `json:"repository,omitempty"`
}

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL