Fix experimental charts and upgrade Helm tooling (#4687)

This commit is contained in:
Nikola Jokic
2026-09-28 15:26:36 +02:00
committed by GitHub
parent 5256d95d89
commit 230e163c77
23 changed files with 1062 additions and 114 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ on:
default: false
env:
HELM_VERSION: v3.8.0
HELM_VERSION: v4.2.2
permissions:
packages: write
+16 -2
View File
@@ -18,7 +18,8 @@ on:
workflow_dispatch:
env:
KUBE_SCORE_VERSION: 1.16.1
HELM_VERSION: v3.19.4
HELM_VERSION: v4.2.2
HELM_UNITTEST_VERSION: 1.1.2
permissions:
contents: read
@@ -105,7 +106,17 @@ jobs:
- name: Install helm-unittest
run: |
helm plugin install https://github.com/helm-unittest/helm-unittest.git
# Pin the upstream signing key independently of the release download.
curl --fail --silent --show-error --location \
https://raw.githubusercontent.com/helm-unittest/helm-unittest/33c48cac798e465deda9a66c8e6c07c0973cf53d/public-key.asc \
--output "${RUNNER_TEMP}/helm-unittest-key.asc"
gpg --batch --yes --dearmor \
--output "${RUNNER_TEMP}/helm-unittest-keyring.gpg" \
"${RUNNER_TEMP}/helm-unittest-key.asc"
helm plugin install \
"https://github.com/helm-unittest/helm-unittest/releases/download/v${HELM_UNITTEST_VERSION}/unittest-${HELM_UNITTEST_VERSION}.tgz" \
--verify \
--keyring "${RUNNER_TEMP}/helm-unittest-keyring.gpg"
- name: Run helm-unittest (gha-runner-scale-set-controller-experimental)
run: |
@@ -125,3 +136,6 @@ jobs:
- name: Test gha-runner-scale-set-controller
run: go test ./charts/gha-runner-scale-set-controller/...
- name: Test gha-runner-scale-set-experimental
run: go test ./charts/gha-runner-scale-set-experimental/...
+9
View File
@@ -5,16 +5,21 @@ on:
- master
paths:
- ".github/workflows/go.yaml"
- "charts/gha-runner-scale-set*/**"
- "**.go"
- "go.mod"
- "go.sum"
pull_request:
paths:
- ".github/workflows/go.yaml"
- "charts/gha-runner-scale-set*/**"
- "**.go"
- "go.mod"
- "go.sum"
env:
HELM_VERSION: v4.2.2
permissions:
contents: read
@@ -86,6 +91,10 @@ jobs:
- uses: actions/setup-go@v7
with:
go-version-file: "go.mod"
- name: Set up Helm
uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310
with:
version: ${{ env.HELM_VERSION }}
- run: make manifests
- name: Check diff
run: git diff --exit-code
+12
View File
@@ -101,6 +101,11 @@ NAME=$DOCKER_USER/actions-runner make \
A set of example pipelines (./acceptance/pipelines) are provided in this repository which you can use to validate your runners are working as expected.
When raising a PR please run the relevant suites to prove your change hasn't broken anything.
Go chart tests and controller chart-contract tests require the `helm` CLI on `PATH`.
Install the version listed under [Helm Version Changes](#helm-version-changes)
before running `go test ./...` or `make test`. The make targets provision envtest,
not Helm; the helm-unittest plugin is not required for Go tests.
#### Running Ginkgo Tests
You can run the integration test suite that is written in Ginkgo with:
@@ -201,6 +206,13 @@ Send PR, add issue number to description
In general we ask you not to bump the version in your PR.
The maintainers will manage releases and publishing new charts.
The Go test job and scale-set chart validation and publishing workflows use
Helm CLI v4.2.2.
Keep their `HELM_VERSION` pins aligned when updating the CLI. Validation uses
helm-unittest v1.1.2 from its signed release archive; the workflow verifies it
with the pinned upstream signing key. Legacy ARC workflows retain their separate
Helm version. These CLI pins do not change chart versions or require Helm 4 for users.
## Testing Controller Built from a Pull Request
We always appreciate your help in testing open pull requests by deploying custom builds of actions-runner-controller onto your own environment, so that we are extra sure we didn't break anything.
@@ -97,9 +97,12 @@ args:
{{- end }}
{{- $ports := list -}}
{{- if .Values.controller.metrics }}
{{- $metricsPort := dict "containerPort" ((regexReplaceAll ":([0-9]+)" .Values.controller.metrics.controllerManagerAddr "${1}") | int) "protocol" "TCP" "name" "metrics" -}}
{{- $port := include "gha-controller.metrics-port" .Values.controller.metrics.controllerManagerAddr -}}
{{- if $port }}
{{- $metricsPort := dict "containerPort" (int $port) "protocol" "TCP" "name" "metrics" -}}
{{- $ports = append $ports $metricsPort -}}
{{- end }}
{{- end }}
{{- with .Values.controller.manager.container.extraPorts }}
{{- if kindIs "slice" . }}
{{- $ports = concat $ports . -}}
@@ -0,0 +1,87 @@
{{/*
The bind address must retain its host in --metrics-addr, but containerPort only
accepts a numeric port. "0" disables the server and must not create a port.
*/}}
{{- define "gha-controller.metrics-port" -}}
{{- $address := . -}}
{{- $error := "controller.metrics.controllerManagerAddr must be \"0\" or a host:port address with a numeric port between 1 and 65535 (IPv6 hosts must be bracketed)" -}}
{{- if ne (toString $address) "0" -}}
{{- if not (kindIs "string" $address) -}}
{{- fail $error -}}
{{- end -}}
{{- if not (regexMatch `^(\[[^]]+\]|[^:]*):[0-9]+$` $address) -}}
{{- fail $error -}}
{{- end -}}
{{- $portString := regexFind "[0-9]+$" $address -}}
{{- $port := atoi $portString -}}
{{- if or (lt $port 1) (gt $port 65535) -}}
{{- fail $error -}}
{{- end -}}
{{- $host := trimSuffix (printf ":%s" $portString) $address -}}
{{- if hasPrefix "[" $host -}}
{{- $ip := trimSuffix "]" (trimPrefix "[" $host) -}}
{{- $zone := splitList "%" $ip -}}
{{- if gt (len $zone) 2 -}}
{{- fail $error -}}
{{- end -}}
{{- if eq (len $zone) 2 -}}
{{- if not (regexMatch "^[A-Za-z0-9_.-]+$" (index $zone 1)) -}}
{{- fail $error -}}
{{- end -}}
{{- end -}}
{{- $ip = index $zone 0 -}}
{{- if contains "." $ip -}}
{{- $ipv4 := last (splitList ":" $ip) -}}
{{- include "gha-controller.validate-ipv4" (dict "ip" $ipv4 "error" $error) -}}
{{- $ip = printf "%s0:0" (trimSuffix $ipv4 $ip) -}}
{{- end -}}
{{- if or (not (regexMatch "^[0-9A-Fa-f:]+$" $ip)) (contains ":::" $ip) (and (hasPrefix ":" $ip) (not (hasPrefix "::" $ip))) (and (hasSuffix ":" $ip) (not (hasSuffix "::" $ip))) -}}
{{- fail $error -}}
{{- end -}}
{{- $halves := splitList "::" $ip -}}
{{- $groups := splitList ":" $ip -}}
{{- $count := 0 -}}
{{- range $groups -}}
{{- if ne . "" -}}
{{- if not (regexMatch "^[0-9A-Fa-f]{1,4}$" .) -}}
{{- fail $error -}}
{{- end -}}
{{- $count = add1 $count -}}
{{- end -}}
{{- end -}}
{{- if eq (len $halves) 1 -}}
{{- if or (ne $count 8) (hasPrefix ":" $ip) (hasSuffix ":" $ip) -}}
{{- fail $error -}}
{{- end -}}
{{- else if or (ne (len $halves) 2) (ge $count 8) -}}
{{- fail $error -}}
{{- end -}}
{{- else if ne $host "" -}}
{{- if or (gt (len $host) 253) (not (regexMatch `^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*\.?$` $host)) -}}
{{- fail $error -}}
{{- end -}}
{{- range splitList "." $host -}}
{{- if gt (len .) 63 -}}
{{- fail $error -}}
{{- end -}}
{{- end -}}
{{- if regexMatch `^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$` $host -}}
{{- include "gha-controller.validate-ipv4" (dict "ip" $host "error" $error) -}}
{{- end -}}
{{- end -}}
{{- $port -}}
{{- end -}}
{{- end -}}
{{- define "gha-controller.validate-ipv4" -}}
{{- $error := .error -}}
{{- $parts := splitList "." .ip -}}
{{- if ne (len $parts) 4 -}}
{{- fail $error -}}
{{- end -}}
{{- range $parts -}}
{{- if or (not (regexMatch "^(0|[1-9][0-9]{0,2})$" .)) (gt (int .) 255) -}}
{{- fail $error -}}
{{- end -}}
{{- end -}}
{{- end -}}
@@ -0,0 +1,48 @@
suite: Controller metrics addresses
templates:
- deployment.yaml
set:
controller.metrics.listenerAddr: ":9090"
controller.metrics.listenerEndpoint: /metrics
tests:
- it: extracts the IPv4 bind port without changing the address
set:
controller.metrics.controllerManagerAddr: "127.0.0.1:8080"
asserts:
- contains:
path: spec.template.spec.containers[0].args
content: "--metrics-addr=127.0.0.1:8080"
- equal:
path: spec.template.spec.containers[0].ports
value: [{name: metrics, containerPort: 8080, protocol: TCP}]
- it: extracts the bracketed IPv6 bind port
set:
controller.metrics.controllerManagerAddr: "[::]:8080"
asserts:
- contains:
path: spec.template.spec.containers[0].args
content: "--metrics-addr=[::]:8080"
- equal:
path: spec.template.spec.containers[0].ports
value: [{name: metrics, containerPort: 8080, protocol: TCP}]
- it: disables only controller metrics and retains extra ports
set:
controller.metrics.controllerManagerAddr: "0"
controller.manager.container.extraPorts:
- {name: custom, containerPort: 9000, protocol: TCP}
asserts:
- contains:
path: spec.template.spec.containers[0].args
content: "--metrics-addr=0"
- contains:
path: spec.template.spec.containers[0].args
content: "--listener-metrics-addr=:9090"
- equal:
path: spec.template.spec.containers[0].ports
value: [{name: custom, containerPort: 9000, protocol: TCP}]
- it: rejects a non-numeric port explicitly
set:
controller.metrics.controllerManagerAddr: "localhost:http"
asserts:
- failedTemplate:
errorMessage: 'controller.metrics.controllerManagerAddr must be "0" or a host:port address with a numeric port between 1 and 65535 (IPv6 hosts must be bracketed)'
@@ -126,8 +126,10 @@ controller:
volumeMounts: []
# Metrics configuration. If omitted, metrics are disabled.
# controllerManagerAddr accepts host:port (e.g. ":8080", "127.0.0.1:8080",
# "[::]:8080", or "localhost:8080"), with a numeric port from 1 to 65535.
# Set it to "0" to disable controller metrics independently of listener metrics.
# metrics:
# controllerManagerAddr: ":8080"
# listenerAddr: ":8080"
# listenerEndpoint: "/metrics"
@@ -115,14 +115,20 @@ spec:
{{- end }}
command:
- "/manager"
{{- if or .Values.metrics .Values.pprof.addr }}
{{- $controllerMetricsEnabled := false }}
{{- with .Values.metrics }}
{{- $controllerMetricsEnabled = ne (toString .controllerManagerAddr) "0" }}
{{- end }}
{{- if or $controllerMetricsEnabled .Values.pprof.addr }}
ports:
{{- end }}
{{- if $controllerMetricsEnabled }}
{{- with .Values.metrics }}
- containerPort: {{ required "Values.metrics.controllerManagerAddr must end with a numeric port" (regexFind "[0-9]+$" .controllerManagerAddr) }}
protocol: TCP
name: metrics
{{- end }}
{{- end }}
{{- if .Values.pprof.addr }}
- containerPort: {{ required "Values.pprof.addr must end with a numeric port" (regexFind "[0-9]+$" .Values.pprof.addr) }}
protocol: TCP
@@ -0,0 +1,136 @@
package tests
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gruntwork-io/terratest/modules/helm"
"github.com/gruntwork-io/terratest/modules/logger"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/yaml"
)
func TestControllerMetricsAddress(t *testing.T) {
for _, chartName := range []string{"gha-runner-scale-set-controller", "gha-runner-scale-set-controller-experimental"} {
t.Run(chartName, func(t *testing.T) {
chart, err := filepath.Abs("../../" + chartName)
require.NoError(t, err)
for _, tc := range []struct {
address string
port int32
}{
{":8080", 8080}, {"127.0.0.1:8080", 8080}, {"[::]:8080", 8080},
{"[2001:db8::1]:9090", 9090}, {"[::ffff:127.0.0.1]:8080", 8080},
{"[fe80::1%eth0]:8080", 8080}, {"localhost:8080", 8080},
{"metrics.example.com:8080", 8080}, {":1", 1}, {":65535", 65535}, {"0", 0},
{"", 0},
} {
t.Run(tc.address, func(t *testing.T) {
values := ""
if tc.address != "" {
values = fmt.Sprintf("metrics:\n controllerManagerAddr: %q\n listenerAddr: ':9090'\n listenerEndpoint: /metrics\n", tc.address)
if chartName == "gha-runner-scale-set-controller-experimental" {
values = "controller:\n" + indentMetricsValues(values)
}
}
path := filepath.Join(t.TempDir(), "values.yaml")
require.NoError(t, os.WriteFile(path, []byte(values), 0600))
output := helm.RenderTemplateContext(t, t.Context(), &helm.Options{
Logger: logger.Discard, ValuesFiles: []string{path},
}, chart, "metrics", []string{"templates/deployment.yaml"})
var deployment appsv1.Deployment
require.NoError(t, yaml.UnmarshalStrict([]byte(output), &deployment))
c := deployment.Spec.Template.Spec.Containers[0]
if tc.port == 0 {
assert.Empty(t, c.Ports)
assert.Contains(t, c.Args, "--metrics-addr=0")
} else {
assert.Equal(t, []corev1.ContainerPort{{Name: "metrics", ContainerPort: tc.port, Protocol: corev1.ProtocolTCP}}, c.Ports)
assert.Contains(t, c.Args, "--metrics-addr="+tc.address)
}
if tc.address != "" {
assert.Contains(t, c.Args, "--listener-metrics-addr=:9090")
assert.Contains(t, c.Args, "--listener-metrics-endpoint=/metrics")
}
})
}
})
}
}
func indentMetricsValues(values string) string {
result := ""
for _, line := range strings.Split(strings.TrimSuffix(values, "\n"), "\n") {
result += " " + line + "\n"
}
return result
}
func TestExperimentalControllerRejectsInvalidMetricsAddress(t *testing.T) {
chart, err := filepath.Abs("../../gha-runner-scale-set-controller-experimental")
require.NoError(t, err)
for _, address := range []string{
"", "8080", ":0", ":65536", ":-1", ":http", ":1.5", ":999999999999999999999",
"127.0.0.1", "http://localhost:8080", "::1:8080", "[::]:http", "[::1:8080",
"[not-an-ip]:8080", "[::::]:8080", "[1:2:3]:8080", "[:1::]:8080", "[::1:]:8080",
"[::1::2]:8080", "[1:2:3:4:5:6:7:8::]:8080", "host name:8080", "bad/host:8080",
"999.999.999.999:8080",
} {
t.Run(address, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "values.yaml")
require.NoError(t, os.WriteFile(path, []byte(fmt.Sprintf("controller:\n metrics:\n controllerManagerAddr: %q\n listenerAddr: ':9090'\n listenerEndpoint: /metrics\n", address)), 0600))
_, err := helm.RenderTemplateContextE(t, t.Context(), &helm.Options{
Logger: logger.Discard, ValuesFiles: []string{path},
}, chart, "metrics", []string{"templates/deployment.yaml"})
require.ErrorContains(t, err, "controller.metrics.controllerManagerAddr")
})
}
}
func TestExperimentalControllerMetricsDecimalPort(t *testing.T) {
chart, err := filepath.Abs("../../gha-runner-scale-set-controller-experimental")
require.NoError(t, err)
for address, port := range map[string]int32{":08080": 8080, "localhost:008080": 8080, ":010": 10} {
t.Run(address, func(t *testing.T) {
output := helm.RenderTemplateContext(t, t.Context(), &helm.Options{
Logger: logger.Discard,
SetValues: map[string]string{
"controller.metrics.controllerManagerAddr": address,
"controller.metrics.listenerAddr": ":9090",
"controller.metrics.listenerEndpoint": "/metrics",
},
}, chart, "metrics", []string{"templates/deployment.yaml"})
var deployment appsv1.Deployment
require.NoError(t, yaml.UnmarshalStrict([]byte(output), &deployment))
assert.Equal(t, port, deployment.Spec.Template.Spec.Containers[0].Ports[0].ContainerPort)
assert.Contains(t, deployment.Spec.Template.Spec.Containers[0].Args, "--metrics-addr="+address)
})
}
}
func TestControllerMetricsDisabledKeepsPprof(t *testing.T) {
chart, err := filepath.Abs("..")
require.NoError(t, err)
output := helm.RenderTemplateContext(t, t.Context(), &helm.Options{
Logger: logger.Discard,
SetValues: map[string]string{
"metrics.controllerManagerAddr": "0",
"metrics.listenerAddr": ":9090",
"metrics.listenerEndpoint": "/metrics",
"pprof.addr": ":6060",
},
}, chart, "metrics", []string{"templates/deployment.yaml"})
var deployment appsv1.Deployment
require.NoError(t, yaml.UnmarshalStrict([]byte(output), &deployment))
c := deployment.Spec.Template.Spec.Containers[0]
assert.Equal(t, []corev1.ContainerPort{{Name: "pprof", ContainerPort: 6060, Protocol: corev1.ProtocolTCP}}, c.Ports)
assert.Contains(t, c.Args, "--metrics-addr=0")
assert.Contains(t, c.Args, "--listener-metrics-addr=:9090")
assert.Contains(t, c.Args, "--pprof-addr=:6060")
}
@@ -93,6 +93,8 @@ priorityClassName: ""
## This will disable metrics.
##
## To enable metrics, uncomment the following lines.
## Set controllerManagerAddr to "0" to disable only controller metrics while
## keeping listener metrics enabled; no controller metrics containerPort is emitted.
# metrics:
# controllerManagerAddr: ":8080"
# listenerAddr: ":8080"
@@ -1,21 +1,7 @@
{{- define "runner-mode-dind.runner-container" -}}
name: runner
image: {{ include "runner.image" . | quote }}
command: {{ include "runner.command" . }}
env:
- {{ include "runner-mode-dind.env-docker-host" . | nindent 4 }}
- {{ include "runner-mode-dind.env-wait-for-docker-timeout" . | nindent 4 }}
{{/* TODO:: Should we skip DOCKER_HOST and RUNNER_WAIT_FOR_DOCKER_IN_SECONDS? */}}
{{- with .Values.runner.env }}
{{- toYaml . | nindent 2 }}
{{- end }}
{{ include "githubServerTLS.envItems" (dict "root" $ "existingEnv" (.Values.runner.env | default list)) | nindent 2 }}
volumeMounts:
- name: work
mountPath: /home/runner/_work
- name: dind-sock
mountPath: {{ include "runner-mode-dind.sock-mount-dir" . | quote }}
{{ include "githubServerTLS.volumeMountItem" (dict "root" $ "existingVolumeMounts" (list)) | nindent 2 }}
{{- $env := list (include "runner-mode-dind.env-docker-host" . | fromYaml) (include "runner-mode-dind.env-wait-for-docker-timeout" . | fromYaml) -}}
{{- $mounts := list (dict "name" "work" "mountPath" "/home/runner/_work") (dict "name" "dind-sock" "mountPath" (include "runner-mode-dind.sock-mount-dir" .)) -}}
{{- include "runner-container.render" (dict "root" . "env" $env "volumeMounts" $mounts "legacyEnv" .Values.runner.env) -}}
{{- end }}
{{- define "runner-mode-dind.dind-container" -}}
@@ -143,7 +129,10 @@ value: {{ $dockerSock | quote }}
{{- define "runner-mode-dind.env-wait-for-docker-timeout" -}}
{{- $dind := .Values.runner.dind | default dict -}}
{{- $waitForDockerInSeconds := $dind.waitForDockerInSeconds | default 120 -}}
{{- $waitForDockerInSeconds := 120 -}}
{{- if hasKey $dind "waitForDockerInSeconds" -}}
{{- $waitForDockerInSeconds = $dind.waitForDockerInSeconds -}}
{{- end -}}
{{- if not (or (kindIs "int" $waitForDockerInSeconds) (kindIs "int64" $waitForDockerInSeconds) (kindIs "float64" $waitForDockerInSeconds)) -}}
{{- fail "runner.dind.waitForDockerInSeconds must be a number" -}}
{{- end -}}
@@ -5,30 +5,5 @@ Container spec that is expanded for the runner container
{{- if not .Values.runner.container }}
{{ fail "You must provide a runner container specification in values.runner.container" }}
{{- end }}
name: runner
image: {{ .Values.runner.container.image | default "ghcr.io/actions/actions-runner:latest" }}
command: {{ toJson (default (list "/home/runner/run.sh") .Values.runner.container.command) }}
{{ $tlsEnvItems := include "githubServerTLS.envItems" (dict "root" $ "existingEnv" (.Values.runner.container.env | default list)) }}
{{ if or .Values.runner.container.env $tlsEnvItems }}
env:
{{- with .Values.runner.container.env }}
{{- toYaml . | nindent 2 }}
{{- end }}
{{ $tlsEnvItems | nindent 2 }}
{{ end }}
{{ $tlsVolumeMountItem := include "githubServerTLS.volumeMountItem" (dict "root" $ "existingVolumeMounts" (.Values.runner.container.volumeMounts | default list)) }}
{{ if or .Values.runner.container.volumeMounts $tlsVolumeMountItem }}
volumeMounts:
{{- with .Values.runner.container.volumeMounts }}
{{- toYaml . | nindent 2 }}
{{- end }}
{{ $tlsVolumeMountItem | nindent 2 }}
{{ end }}
{{ $extra := omit .Values.runner.container "name" "image" "command" "env" "volumeMounts" }}
{{- if not (empty $extra) -}}
{{ toYaml $extra }}
{{- end -}}
{{- include "runner-container.render" (dict "root" .) -}}
{{- end }}
@@ -20,15 +20,6 @@
{{- end -}}
{{- $hasExtension := or (not (empty $extensionRef)) (not (empty $extensionYamlStr)) -}}
{{- $hookTemplatePath := printf "%s/hook-template.yaml" (dir $hookPath) -}}
{{- $setHookTemplateEnv := true -}}
{{- $userEnv := (.Values.runner.env | default list) -}}
{{- if kindIs "slice" $userEnv -}}
{{- range $userEnv -}}
{{- if and (kindIs "map" .) (eq ((index . "name") | default "") "ACTIONS_RUNNER_CONTAINER_HOOK_TEMPLATE") -}}
{{- $setHookTemplateEnv = false -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- if not (kindIs "string" $hookPath) -}}
{{- fail "runner.kubernetesMode.hookPath must be a string" -}}
{{- end -}}
@@ -48,41 +39,20 @@
{{- if not (kindIs "bool" $requireJobContainer) -}}
{{- fail "runner.kubernetesMode.requireJobContainer must be a bool" -}}
{{- end -}}
name: runner
image: {{ include "runner.image" . | quote }}
command: {{ include "runner.command" . }}
{{ $tlsEnvItems := include "githubServerTLS.envItems" (dict "root" $ "existingEnv" (.Values.runner.env | default list)) }}
env:
- name: ACTIONS_RUNNER_CONTAINER_HOOKS
value: {{ $hookPath | quote }}
- name: ACTIONS_RUNNER_POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER
value: {{ ternary "true" "false" $requireJobContainer | quote }}
{{- if not $requireJobContainer -}}
{{- printf "# WARNING: runner.kubernetesMode.requireJobContainer is set to false. This means that the runner container will be used to execute jobs, which may lead to security risks if the runner is compromised. It is recommended to set runner.kubernetesMode.requireJobContainer to true in production environments." }}
{{- end -}}
{{- if and $hasExtension $setHookTemplateEnv }}
- name: ACTIONS_RUNNER_CONTAINER_HOOK_TEMPLATE
value: {{ $hookTemplatePath | quote }}
{{- end }}
{{- with .Values.runner.env }}
{{- toYaml . | nindent 2 }}
{{- end }}
{{ $tlsEnvItems | nindent 2 }}
volumeMounts:
- name: work
mountPath: /home/runner/_work
{{- if $hasExtension }}
- name: hook-extension
mountPath: {{ $hookTemplatePath | quote }}
subPath: extension
readOnly: true
{{- end }}
{{ include "githubServerTLS.volumeMountItem" (dict "root" $ "existingVolumeMounts" (list)) | nindent 2 }}
{{- $env := list
(dict "name" "ACTIONS_RUNNER_CONTAINER_HOOKS" "value" $hookPath)
(dict "name" "ACTIONS_RUNNER_POD_NAME" "valueFrom" (dict "fieldRef" (dict "fieldPath" "metadata.name")))
(dict "name" "ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER" "value" (ternary "true" "false" $requireJobContainer))
-}}
{{- $mounts := list (dict "name" "work" "mountPath" "/home/runner/_work") -}}
{{- if $hasExtension -}}
{{- $env = append $env (dict "name" "ACTIONS_RUNNER_CONTAINER_HOOK_TEMPLATE" "value" $hookTemplatePath) -}}
{{- $mounts = append $mounts (dict "name" "hook-extension" "mountPath" $hookTemplatePath "subPath" "extension" "readOnly" true) -}}
{{- end -}}
{{- if not $requireJobContainer }}
# WARNING: Jobs may execute directly in the runner container when requireJobContainer is false.
{{ end -}}
{{- include "runner-container.render" (dict "root" . "env" $env "volumeMounts" $mounts "legacyEnv" .Values.runner.env) -}}
{{- end }}
{{- define "runner-mode-kubernetes.pod-volumes" -}}
@@ -0,0 +1,82 @@
{{/*
Merge list entries by their Kubernetes identity, replacing whole entries rather
than merging maps (an EnvVar must not acquire both value and valueFrom).
User entries precede defaults, as in the standard runner chart.
*/}}
{{- define "runner-container.merge-list" -}}
{{- $key := .key -}}
{{- $path := .path -}}
{{- $seen := dict -}}
{{- $out := list -}}
{{- range $group := list .overrides .defaults -}}
{{- $groupSeen := dict -}}
{{- range $group -}}
{{- if not (kindIs "map" .) -}}
{{- fail (printf "%s must contain objects with a %s" $path $key) -}}
{{- end -}}
{{- $identity := index . $key -}}
{{- if or (not (kindIs "string" $identity)) (empty $identity) -}}
{{- fail (printf "%s entries must have a non-empty %s" $path $key) -}}
{{- end -}}
{{- if hasKey $groupSeen $identity -}}
{{- fail (printf "%s contains duplicate %s %q" $path $key $identity) -}}
{{- end -}}
{{- $_ := set $groupSeen $identity true -}}
{{- if not (hasKey $seen $identity) -}}
{{- $out = append $out . -}}
{{- $_ := set $seen $identity true -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{- toYaml $out -}}
{{- end -}}
{{/*
All modes share the documented runner.container customization surface.
Keep Values immutable: mode and TLS defaults are built separately and only a
deep copy of the user's container is modified.
*/}}
{{- define "runner-container.render" -}}
{{- $root := .root -}}
{{- $container := $root.Values.runner.container | default dict -}}
{{- if not (kindIs "map" $container) -}}
{{- fail "runner.container must be a map/object" -}}
{{- end -}}
{{- range $field := list "env" "volumeMounts" "args" -}}
{{- if and (hasKey $container $field) (not (kindIs "slice" (index $container $field))) -}}
{{- fail (printf "runner.container.%s must be a list" $field) -}}
{{- end -}}
{{- end -}}
{{- range $field := list "resources" "securityContext" -}}
{{- if and (hasKey $container $field) (not (kindIs "map" (index $container $field))) -}}
{{- fail (printf "runner.container.%s must be a map/object" $field) -}}
{{- end -}}
{{- end -}}
{{- if hasKey $container "volumes" -}}
{{- fail "runner.container.volumes is not supported; use runner.pod.spec.volumes" -}}
{{- end -}}
{{- $out := deepCopy (omit $container "name" "image" "command" "env" "volumeMounts") -}}
{{- $_ := set $out "name" "runner" -}}
{{- $_ := set $out "image" (include "runner.image" $root) -}}
{{- $_ := set $out "command" (include "runner.command" $root | fromJsonArray) -}}
{{- $env := $container.env | default list -}}
{{- if .legacyEnv -}}
{{- if not (kindIs "slice" .legacyEnv) -}}
{{- fail "runner.env must be a list; use runner.container.env" -}}
{{- end -}}
{{- $env = include "runner-container.merge-list" (dict "key" "name" "path" "runner.container.env" "overrides" $env "defaults" .legacyEnv) | fromYamlArray -}}
{{- end -}}
{{- $env = include "runner-container.merge-list" (dict "key" "name" "path" "runner.container.env" "overrides" $env "defaults" (.env | default list)) | fromYamlArray -}}
{{- $tlsEnv := include "githubServerTLS.envItems" (dict "root" $root "existingEnv" $env) | fromYamlArray -}}
{{- $env = concat $env $tlsEnv -}}
{{- if $env -}}
{{- $_ := set $out "env" $env -}}
{{- end -}}
{{- $mounts := include "runner-container.merge-list" (dict "key" "mountPath" "path" "runner.container.volumeMounts" "overrides" ($container.volumeMounts | default list) "defaults" (.volumeMounts | default list)) | fromYamlArray -}}
{{- $tlsMounts := include "githubServerTLS.volumeMountItem" (dict "root" $root "existingVolumeMounts" $mounts) | fromYamlArray -}}
{{- $mounts = include "runner-container.merge-list" (dict "key" "mountPath" "path" "runner.container.volumeMounts" "overrides" $mounts "defaults" $tlsMounts) | fromYamlArray -}}
{{- if $mounts -}}
{{- $_ := set $out "volumeMounts" $mounts -}}
{{- end -}}
{{- toYaml $out -}}
{{- end -}}
@@ -0,0 +1,16 @@
{{- define "secret-resolution.type" -}}
{{- $config := .Values.secretResolution -}}
{{- include "assert-map" (dict "value" $config "path" ".Values.secretResolution") -}}
{{- if not $config -}}
kubernetes
{{- else -}}
{{- $type := $config.type -}}
{{- if not (kindIs "string" $type) -}}
{{- fail (printf "Unsupported keyVault type: %v" $type) -}}
{{- end -}}
{{- if not (has $type (list "kubernetes" "azureKeyVault")) -}}
{{- fail (printf "Unsupported keyVault type: %s" $type) -}}
{{- end -}}
{{- $type -}}
{{- end -}}
{{- end -}}
@@ -5,7 +5,7 @@
{{- $dind := (index $runner "dind" | default dict) }}
{{- $kubeDefaults := (index $kubeMode "default" | default true) }}
{{- $kubeServiceAccountName := (index $kubeMode "serviceAccountName" | default "") }}
{{- $usesKubernetesSecrets := or (not .Values.secretResolution) (eq .Values.secretResolution.type "kubernetes") }}
{{- $usesKubernetesSecrets := eq (include "secret-resolution.type" .) "kubernetes" }}
{{- /* All listener configuration lives under .Values.listener */ -}}
{{- if hasKey .Values "listenerMetrics" }}
{{- fail ".Values.listenerMetrics has moved to .Values.listener.metrics" }}
@@ -156,22 +156,20 @@ spec:
{{- end }}
{{- end }}
{{- if and .Values.secretResolution (ne .Values.secretResolution.type "kubernetes") }}
{{- if not $usesKubernetesSecrets }}
vaultConfig:
type: {{ .Values.secretResolution.type }}
type: azure_key_vault
{{- if .Values.secretResolution.proxy }}
proxy: {{- toYaml .Values.secretResolution.proxy | nindent 6 }}
{{- end }}
{{- if eq .Values.secretResolution.type "azureKeyVault" }}
{{- if hasKey (.Values.secretResolution.azureKeyVault | default dict) "secretKey" }}
{{- fail "secretResolution.azureKeyVault.secretKey is not supported; use auth.secretName to select the vault secret" }}
{{- end }}
azureKeyVault:
url: {{ .Values.secretResolution.azureKeyVault.url }}
tenantId: {{ .Values.secretResolution.azureKeyVault.tenantId }}
clientId: {{ .Values.secretResolution.azureKeyVault.clientId }}
certificatePath: {{ .Values.secretResolution.azureKeyVault.certificatePath }}
secretKey: {{ .Values.secretResolution.azureKeyVault.secretKey }}
{{- else }}
{{- fail (printf "Unsupported keyVault type: %s" .Values.secretResolution.type) }}
{{- end }}
{{- end }}
{{- if .Values.proxy }}
@@ -310,6 +308,9 @@ spec:
{{- end }}
spec:
serviceAccountName: {{ include "autoscaling-runner-set.template-service-account" . | quote }}
{{- if not (hasKey $runnerPodSpec "restartPolicy") }}
restartPolicy: Never
{{- end }}
{{- if $hasInitContainers }}
initContainers:
{{- if and (eq $runnerMode "dind") $dind.copyRunnerExternals }}
@@ -1,5 +1,5 @@
{{- include "validate-all-metadata" . }}
{{- $usesKubernetesSecrets := or (not .Values.secretResolution) (eq .Values.secretResolution.type "kubernetes") -}}
{{- $usesKubernetesSecrets := eq (include "secret-resolution.type" .) "kubernetes" -}}
{{- if and (not $usesKubernetesSecrets) (empty .Values.auth.secretName) -}}
{{- fail ".Values.auth.secretName is required when .Values.secretResolution.type is not \"kubernetes\"" -}}
@@ -0,0 +1,62 @@
suite: Runner container customization and restart policy
templates:
- autoscalingrunnserset.yaml
set:
auth.url: https://github.com/example
auth.secretName: existing-auth
controllerServiceAccount.name: controller
controllerServiceAccount.namespace: arc-system
runner.container.env:
- {name: CUSTOM_MARKER, value: expected}
runner.container.resources.requests: {cpu: 250m, memory: 512Mi}
runner.container.securityContext: {runAsUser: 1000, runAsGroup: 0, privileged: false}
runner.container.volumeMounts:
- {name: custom, mountPath: /custom}
runner.pod.spec.volumes:
- {name: custom, emptyDir: {}}
tests:
- it: preserves manual container customization and defaults to Never
set:
runner.mode: ""
asserts: &container-contract
- equal:
path: spec.template.spec.restartPolicy
value: Never
- contains:
path: spec.template.spec.containers[0].env
content: {name: CUSTOM_MARKER, value: expected}
- equal:
path: spec.template.spec.containers[0].resources.requests
value: {cpu: 250m, memory: 512Mi}
- equal:
path: spec.template.spec.containers[0].securityContext
value: {runAsUser: 1000, runAsGroup: 0, privileged: false}
- contains:
path: spec.template.spec.containers[0].volumeMounts
content: {name: custom, mountPath: /custom}
- it: preserves dind container customization and defaults to Never
set:
runner.mode: dind
asserts: *container-contract
- it: preserves kubernetes container customization and defaults to Never
set:
runner.mode: kubernetes
asserts: *container-contract
- it: preserves a manual restart policy override
set:
runner.mode: ""
runner.pod.spec.restartPolicy: OnFailure
asserts: &restart-override
- equal:
path: spec.template.spec.restartPolicy
value: OnFailure
- it: preserves a dind restart policy override
set:
runner.mode: dind
runner.pod.spec.restartPolicy: OnFailure
asserts: *restart-override
- it: preserves a kubernetes restart policy override
set:
runner.mode: kubernetes
runner.pod.spec.restartPolicy: OnFailure
asserts: *restart-override
@@ -23,6 +23,7 @@ tests:
scaleset.name: "test"
auth.url: "https://github.com/org"
auth.githubToken: "gh_token12345"
auth.secretName: "azure-auth"
controllerServiceAccount.name: "arc"
controllerServiceAccount.namespace: "arc-system"
secretResolution:
@@ -32,14 +33,16 @@ tests:
tenantId: "tenant-123"
clientId: "client-456"
certificatePath: "/etc/certs/akv.pem"
secretKey: "secret-key-name"
release:
name: "test-name"
namespace: "test-namespace"
asserts:
- equal:
path: spec.vaultConfig.type
value: azureKeyVault
value: azure_key_vault
- equal:
path: spec.githubConfigSecret
value: azure-auth
- equal:
path: spec.vaultConfig.azureKeyVault.url
value: "https://myvault.vault.azure.net"
@@ -52,9 +55,8 @@ tests:
- equal:
path: spec.vaultConfig.azureKeyVault.certificatePath
value: "/etc/certs/akv.pem"
- equal:
- notExists:
path: spec.vaultConfig.azureKeyVault.secretKey
value: "secret-key-name"
- it: should render vaultConfig proxy when configured
set:
@@ -76,7 +78,6 @@ tests:
tenantId: "tenant-123"
clientId: "client-456"
certificatePath: "/etc/certs/akv.pem"
secretKey: "secret-key-name"
release:
name: "test-name"
namespace: "test-namespace"
@@ -108,3 +109,15 @@ tests:
asserts:
- failedTemplate:
errorMessage: "Unsupported keyVault type: hashicorpVault"
- it: rejects the unused secretKey rather than silently changing the selector
set:
auth.url: https://github.com/example
auth.secretName: azure-auth
controllerServiceAccount.name: controller
controllerServiceAccount.namespace: arc-system
secretResolution.type: azureKeyVault
secretResolution.azureKeyVault.secretKey: ignored
asserts:
- failedTemplate:
errorMessage: "secretResolution.azureKeyVault.secretKey is not supported; use auth.secretName to select the vault secret"
@@ -0,0 +1,364 @@
package tests
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
actionsv1alpha1 "github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1"
"github.com/actions/actions-runner-controller/controllers/actions.github.com/secretresolver"
"github.com/actions/actions-runner-controller/vault"
"github.com/actions/actions-runner-controller/vault/azurekeyvault"
"github.com/gruntwork-io/terratest/modules/helm"
"github.com/gruntwork-io/terratest/modules/k8s"
"github.com/gruntwork-io/terratest/modules/logger"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/yaml"
)
const contractValues = `
auth:
url: https://github.com/example
secretName: existing-auth
controllerServiceAccount:
name: controller
namespace: arc-system
`
func renderContract(t *testing.T, values string, set map[string]string) (string, error) {
t.Helper()
path := filepath.Join(t.TempDir(), "values.yaml")
require.NoError(t, os.WriteFile(path, []byte(contractValues+values), 0600))
chart, err := filepath.Abs("..")
require.NoError(t, err)
return helm.RenderTemplateContextE(t, t.Context(), &helm.Options{
Logger: logger.Discard,
ValuesFiles: []string{path},
SetValues: set,
KubectlOptions: k8s.NewKubectlOptions("", "", "runners"),
}, chart, "contract", []string{"templates/autoscalingrunnserset.yaml"})
}
func runnerSetContract(t *testing.T, values string, set map[string]string) *actionsv1alpha1.AutoscalingRunnerSet {
t.Helper()
output, err := renderContract(t, values, set)
require.NoError(t, err)
var ars actionsv1alpha1.AutoscalingRunnerSet
require.NoError(t, yaml.UnmarshalStrict([]byte(output), &ars))
return &ars
}
func TestExperimentalAzureVaultDispatch(t *testing.T) {
certificate := filepath.Join(t.TempDir(), "missing.pem")
output, err := renderContract(t, fmt.Sprintf(`
secretResolution:
type: azureKeyVault
azureKeyVault:
url: https://example.vault.azure.net
tenantId: tenant
clientId: client
certificatePath: %q
`, certificate), nil)
require.NoError(t, err)
var ars actionsv1alpha1.AutoscalingRunnerSet
assert.NoError(t, yaml.UnmarshalStrict([]byte(output), &ars))
require.NotNil(t, ars.Spec.VaultConfig)
assert.Equal(t, vault.VaultTypeAzureKeyVault, ars.Spec.VaultConfig.Type)
assert.NoError(t, ars.Spec.VaultConfig.Type.Validate())
assert.Equal(t, "tenant", ars.Spec.VaultConfig.AzureKeyVault.TenantID)
assert.Equal(t, "client", ars.Spec.VaultConfig.AzureKeyVault.ClientID)
assert.Equal(t, certificate, ars.Spec.VaultConfig.AzureKeyVault.CertificatePath)
assert.Equal(t, "existing-auth", ars.Spec.GitHubConfigSecret)
assert.Equal(t, "existing-auth", ars.GitHubConfigSecret(), "the selector passed to the vault resolver")
// A missing local certificate proves dispatch reached Azure without any network or credentials.
resolver := secretresolver.New(fake.NewClientBuilder().Build(), nil)
_, err = resolver.GetAppConfig(t.Context(), &ars)
require.ErrorContains(t, err, "failed to create Azure Key Vault client")
assert.ErrorContains(t, err, "cert path")
assert.ErrorContains(t, err, "does not exist")
assert.NotContains(t, err.Error(), "unknown vault type")
validCertificate, err := filepath.Abs("../../../vault/azurekeyvault/testdata/server.crt")
require.NoError(t, err)
valid := runnerSetContract(t, fmt.Sprintf(`
secretResolution:
type: azureKeyVault
azureKeyVault:
url: https://example.vault.azure.net
tenantId: tenant
clientId: client
certificatePath: %q
`, validCertificate), nil)
config := valid.Spec.VaultConfig.AzureKeyVault
azureConfig := azurekeyvault.Config{
URL: config.URL, TenantID: config.TenantID, ClientID: config.ClientID, CertificatePath: config.CertificatePath,
}
assert.NoError(t, azureConfig.Validate())
for _, discriminator := range []string{"azure_key_vault", "hashicorpVault", "", "true"} {
t.Run("unsupported/"+discriminator, func(t *testing.T) {
_, err := renderContract(t, fmt.Sprintf("secretResolution:\n type: %q\n", discriminator), nil)
require.ErrorContains(t, err, "Unsupported keyVault type")
})
}
for _, discriminator := range []string{"true", "42", "[]", "{}"} {
t.Run("invalid-type/"+discriminator, func(t *testing.T) {
_, err := renderContract(t, "secretResolution:\n type: "+discriminator+"\n", nil)
require.ErrorContains(t, err, "Unsupported keyVault type")
})
}
_, err = renderContract(t, "secretResolution:\n type: azureKeyVault\n azureKeyVault:\n secretKey: ignored\n", nil)
require.ErrorContains(t, err, "secretResolution.azureKeyVault.secretKey is not supported; use auth.secretName")
assert.Nil(t, runnerSetContract(t, "", nil).Spec.VaultConfig)
}
const customizedRunner = `
runner:
container:
name: ignored
image: example.com/custom-runner:v1
command: ["/custom/run"]
args: ["--custom"]
imagePullPolicy: Always
stdin: false
tty: false
env:
- {name: CUSTOM_MARKER, value: expected}
resources:
requests: {cpu: 250m, memory: 512Mi}
limits: {cpu: "1", memory: 1Gi}
securityContext:
runAsUser: 1000
runAsGroup: 0
privileged: false
allowPrivilegeEscalation: false
volumeMounts:
- {name: custom, mountPath: /custom, readOnly: false}
pod:
spec:
volumes:
- {name: custom, emptyDir: {}}
`
func TestExperimentalRunnerCustomization(t *testing.T) {
for _, mode := range []string{"", "dind", "kubernetes"} {
for _, namespace := range []string{"runners", "custom-namespace"} {
t.Run(mode+"/"+namespace, func(t *testing.T) {
set := map[string]string{"runner.mode": mode, "namespaceOverride": namespace}
ars := runnerSetContract(t, customizedRunner, set)
assert.Equal(t, namespace, ars.Namespace)
require.NotEmpty(t, ars.Spec.Template.Spec.Containers)
c := ars.Spec.Template.Spec.Containers[0]
assert.Equal(t, "runner", c.Name)
assert.Equal(t, "example.com/custom-runner:v1", c.Image)
assert.Equal(t, []string{"/custom/run"}, c.Command)
assert.Equal(t, []string{"--custom"}, c.Args)
assert.Equal(t, corev1.PullAlways, c.ImagePullPolicy)
assert.Contains(t, c.Env, corev1.EnvVar{Name: "CUSTOM_MARKER", Value: "expected"})
assert.Equal(t, "250m", c.Resources.Requests.Cpu().String())
assert.Equal(t, "512Mi", c.Resources.Requests.Memory().String())
assert.Equal(t, "1", c.Resources.Limits.Cpu().String())
assert.Equal(t, "1Gi", c.Resources.Limits.Memory().String())
require.NotNil(t, c.SecurityContext)
assert.Equal(t, int64(1000), *c.SecurityContext.RunAsUser)
assert.Equal(t, int64(0), *c.SecurityContext.RunAsGroup)
assert.False(t, *c.SecurityContext.Privileged)
assert.False(t, *c.SecurityContext.AllowPrivilegeEscalation)
assert.Contains(t, c.VolumeMounts, corev1.VolumeMount{Name: "custom", MountPath: "/custom"})
assertUniqueContainerLists(t, c)
defaults := runnerSetContract(t, "", set).Spec.Template.Spec.Containers[0]
assert.Equal(t, "ghcr.io/actions/actions-runner:latest", defaults.Image)
assert.Equal(t, []string{"/home/runner/run.sh"}, defaults.Command)
for _, env := range defaults.Env {
assert.Contains(t, c.Env, env)
}
for _, mount := range defaults.VolumeMounts {
assert.Contains(t, c.VolumeMounts, mount)
}
if mode == "dind" {
assert.Contains(t, c.Env, corev1.EnvVar{Name: "DOCKER_HOST", Value: "unix:///var/run/docker.sock"})
assert.Equal(t, "dind", ars.Spec.Template.Spec.InitContainers[1].Name)
assert.Equal(t, corev1.ContainerRestartPolicyAlways, *ars.Spec.Template.Spec.InitContainers[1].RestartPolicy)
}
if mode == "kubernetes" {
assert.Contains(t, c.Env, corev1.EnvVar{Name: "ACTIONS_RUNNER_CONTAINER_HOOKS", Value: "/home/runner/k8s/index.js"})
}
})
}
}
}
func assertUniqueContainerLists(t *testing.T, c corev1.Container) {
t.Helper()
envs, mounts := map[string]bool{}, map[string]bool{}
for _, env := range c.Env {
assert.False(t, envs[env.Name], "duplicate env %s", env.Name)
envs[env.Name] = true
assert.False(t, env.Value != "" && env.ValueFrom != nil, "value and valueFrom on %s", env.Name)
}
for _, mount := range c.VolumeMounts {
assert.False(t, mounts[mount.MountPath], "duplicate mountPath %s", mount.MountPath)
mounts[mount.MountPath] = true
}
}
func TestExperimentalRunnerOverrides(t *testing.T) {
for _, mode := range []string{"", "dind", "kubernetes"} {
t.Run(mode, func(t *testing.T) {
ars := runnerSetContract(t, `
githubServerTLS:
runnerMountPath: /certs
certificateFrom:
configMapKeyRef: {name: ca, key: ca.crt}
runner:
kubernetesMode:
extensionRef: hooks
requireJobContainer: false
dind:
waitForDockerInSeconds: 0
container:
env:
- name: DOCKER_HOST
valueFrom:
secretKeyRef: {name: docker-host, key: address}
- {name: ACTIONS_RUNNER_POD_NAME, value: custom-pod}
- {name: ACTIONS_RUNNER_CONTAINER_HOOK_TEMPLATE, value: /custom/hooks.yaml}
- {name: NODE_EXTRA_CA_CERTS, value: /custom/ca.crt}
- {name: RUNNER_UPDATE_CA_CERTS, value: "0"}
volumeMounts:
- {name: work, mountPath: /home/runner/_work, readOnly: false}
- {name: hook-extension, mountPath: /home/runner/k8s/hook-template.yaml, readOnly: false}
- {name: github-server-tls-cert, mountPath: /custom/certs, readOnly: false}
`, map[string]string{"runner.mode": mode})
c := ars.Spec.Template.Spec.Containers[0]
assertUniqueContainerLists(t, c)
envs := map[string]corev1.EnvVar{}
for _, env := range c.Env {
envs[env.Name] = env
}
assert.Equal(t, "", envs["DOCKER_HOST"].Value)
require.NotNil(t, envs["DOCKER_HOST"].ValueFrom)
assert.Equal(t, "docker-host", envs["DOCKER_HOST"].ValueFrom.SecretKeyRef.Name)
assert.Equal(t, "custom-pod", envs["ACTIONS_RUNNER_POD_NAME"].Value)
assert.Nil(t, envs["ACTIONS_RUNNER_POD_NAME"].ValueFrom)
assert.Equal(t, "/custom/hooks.yaml", envs["ACTIONS_RUNNER_CONTAINER_HOOK_TEMPLATE"].Value)
assert.Equal(t, "/custom/ca.crt", envs["NODE_EXTRA_CA_CERTS"].Value)
assert.Equal(t, "0", envs["RUNNER_UPDATE_CA_CERTS"].Value)
assert.Contains(t, c.VolumeMounts, corev1.VolumeMount{Name: "hook-extension", MountPath: "/home/runner/k8s/hook-template.yaml"})
assert.Contains(t, c.VolumeMounts, corev1.VolumeMount{Name: "github-server-tls-cert", MountPath: "/custom/certs"})
if mode == "dind" {
assert.Equal(t, "0", envs["RUNNER_WAIT_FOR_DOCKER_IN_SECONDS"].Value)
assert.Contains(t, c.VolumeMounts, corev1.VolumeMount{Name: "dind-sock", MountPath: "/var/run"})
}
if mode == "kubernetes" {
assert.Equal(t, "false", envs["ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER"].Value)
}
})
}
}
func TestExperimentalRunnerRestartPolicy(t *testing.T) {
for _, mode := range []string{"", "dind", "kubernetes"} {
for _, policy := range []string{"", "Never", "OnFailure", "Always"} {
t.Run(mode+"/"+policy, func(t *testing.T) {
set := map[string]string{"runner.mode": mode}
want := corev1.RestartPolicyNever
if policy != "" {
set["runner.pod.spec.restartPolicy"] = policy
want = corev1.RestartPolicy(policy)
}
ars := runnerSetContract(t, "", set)
assert.Equal(t, want, ars.Spec.Template.Spec.RestartPolicy)
})
}
}
}
func TestExperimentalRunnerIncludesDoNotMutateValues(t *testing.T) {
chart := filepath.Join(t.TempDir(), "chart")
require.NoError(t, os.CopyFS(chart, os.DirFS("..")))
probe := `
apiVersion: v1
kind: ConfigMap
metadata:
name: probe
data:
{{- $before := toJson .Values }}
{{- $first := include "runner-mode-dind.runner-container" . }}
{{- $second := include "runner-mode-kubernetes.runner-container" . }}
unchanged: {{ eq $before (toJson .Values) | quote }}
repeatable: {{ eq $first (include "runner-mode-dind.runner-container" .) | quote }}
kubernetesRepeatable: {{ eq $second (include "runner-mode-kubernetes.runner-container" .) | quote }}
`
require.NoError(t, os.WriteFile(filepath.Join(chart, "templates/probe.yaml"), []byte(probe), 0600))
values := filepath.Join(t.TempDir(), "values.yaml")
require.NoError(t, os.WriteFile(values, []byte(contractValues+customizedRunner), 0600))
output := helm.RenderTemplateContext(t, t.Context(), &helm.Options{
Logger: logger.Discard, ValuesFiles: []string{values},
}, chart, "contract", []string{"templates/probe.yaml"})
var probeConfig corev1.ConfigMap
require.NoError(t, yaml.UnmarshalStrict([]byte(output), &probeConfig))
for key, value := range probeConfig.Data {
assert.Equal(t, "true", value, key)
}
}
func TestExperimentalRunnerRejectsMalformedLists(t *testing.T) {
for _, mode := range []string{"", "dind", "kubernetes"} {
for _, field := range []string{"env", "volumeMounts"} {
for _, value := range []string{"false", "0", `"invalid"`, "{}"} {
t.Run(strings.Join([]string{mode, field, value}, "/"), func(t *testing.T) {
_, err := renderContract(t, fmt.Sprintf("runner:\n container:\n %s: %s\n", field, value), map[string]string{"runner.mode": mode})
require.ErrorContains(t, err, "runner.container."+field+" must be a list")
})
}
}
}
}
func TestExperimentalRunnerLegacyEnvPrecedence(t *testing.T) {
for _, mode := range []string{"dind", "kubernetes"} {
t.Run(mode, func(t *testing.T) {
ars := runnerSetContract(t, `
runner:
env:
- {name: LEGACY_ONLY, value: retained}
- {name: OVERRIDE, value: old}
- name: DOCKER_HOST
valueFrom:
secretKeyRef: {name: docker-host, key: address}
container:
env:
- {name: OVERRIDE, value: new}
- {name: DOCKER_HOST, value: "unix:///custom/docker.sock"}
`, map[string]string{"runner.mode": mode})
c := ars.Spec.Template.Spec.Containers[0]
assertUniqueContainerLists(t, c)
assert.Contains(t, c.Env, corev1.EnvVar{Name: "LEGACY_ONLY", Value: "retained"})
assert.Contains(t, c.Env, corev1.EnvVar{Name: "OVERRIDE", Value: "new"})
assert.Contains(t, c.Env, corev1.EnvVar{Name: "DOCKER_HOST", Value: "unix:///custom/docker.sock"})
})
}
}
func TestExperimentalRunnerRejectsDuplicateIdentities(t *testing.T) {
for _, mode := range []string{"", "dind", "kubernetes"} {
for field, list := range map[string]string{
"env": "[{name: DUP, value: first}, {name: DUP, value: second}]",
"volumeMounts": "[{name: a, mountPath: /same}, {name: b, mountPath: /same}]",
} {
t.Run(mode+"/"+field, func(t *testing.T) {
_, err := renderContract(t, "runner:\n container:\n "+field+": "+list+"\n", map[string]string{"runner.mode": mode})
require.ErrorContains(t, err, "runner.container."+field+" contains duplicate")
})
}
}
}
@@ -41,7 +41,7 @@ secretResolution:
# Name of the secret resolver to use.
# Available values:
# - "kubernetes" - use Kubernetes secrets
# - "azureKeyVault" - use Azure Key Vault
# - "azureKeyVault" - use Azure Key Vault (rendered as the API type "azure_key_vault")
type: "kubernetes"
## Proxy settings when type is NOT "kubernetes"
# proxy:
@@ -55,12 +55,15 @@ secretResolution:
# - example.com
# - example.org
## Configuration for Azure Key Vault integration
## Configuration for Azure Key Vault integration.
## auth.secretName selects the Azure secret containing the GitHub app/token configuration.
## azureKeyVault.secretKey is not supported; it was never used by the API/resolver
## and is now rejected rather than silently ignored. Set auth.secretName explicitly.
# azureKeyVault:
# url: ""
# client_id: ""
# tenant_id: ""
# certificate_path: ""
# clientId: ""
# tenantId: ""
# certificatePath: ""
## Proxy can be used to define proxy settings that will be used by the
## controller, the listener and the runner of this scale set.
@@ -226,6 +229,7 @@ runner:
# - spec.containers: appended after the generated "runner" container (name "runner" is reserved)
# - spec.initContainers: appended after any generated initContainers (e.g. dind mode)
# - spec.volumes: appended after generated volumes
# - spec.restartPolicy: defaults to Never; an explicit Kubernetes restart policy is preserved
#
# Note: serviceAccountName is managed by the chart and cannot be overridden via runner.pod.spec.
spec:
@@ -234,7 +238,15 @@ runner:
initContainers: []
volumes: []
# container field is applied to the container named "runner". You cannot override the name of the runner container
# Applied to the container named "runner" in every mode; its name cannot be overridden.
# image and command use the defaults below when omitted. Other container fields
# (e.g. resources, securityContext, args) are passed through, including false/zero values.
# env entries replace mode defaults by name; volumeMounts replace them by mountPath.
# Replacements are whole entries, not merged maps. Unspecified mode defaults remain.
# TLS defaults also respect an existing mount named github-server-tls-cert.
# Duplicate env names or mountPaths within a list are rejected.
# Overrides of Docker/hook env or mounts must remain compatible with the selected mode.
# In dind/kubernetes mode, the older runner.env is still read, but container.env takes precedence.
container:
image: "ghcr.io/actions/actions-runner:latest"
command: ["/home/runner/run.sh"]
@@ -0,0 +1,145 @@
package actionsgithubcom
import (
"context"
"fmt"
"os/exec"
"path/filepath"
"strings"
actionsv1alpha1 "github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/yaml"
)
func renderAdmissionChart(ctx context.Context, chart, template, namespace, values string) []byte {
GinkgoHelper()
cmd := exec.CommandContext(ctx, "helm", "template", "chart-contract",
filepath.Join("../../charts", chart), "--namespace", namespace,
"--show-only", "templates/"+template, "--values", "-")
cmd.Stdin = strings.NewReader(values)
output, err := cmd.CombinedOutput()
Expect(err).NotTo(HaveOccurred(), string(output))
return output
}
var _ = Describe("Experimental chart contracts", func() {
var namespace string
BeforeEach(func(ctx SpecContext) {
ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{GenerateName: "chart-contract-"}}
Expect(k8sClient.Create(ctx, ns)).To(Succeed())
namespace = ns.Name
DeferCleanup(func(ctx SpecContext) {
Expect(k8sClient.Delete(ctx, ns)).To(Succeed())
})
})
for _, mode := range []string{"", "dind", "kubernetes"} {
for _, policy := range []string{"", "Never", "OnFailure", "Always"} {
It(fmt.Sprintf("admits runner mode %q restartPolicy %q through ResourceBuilder", mode, policy), func(ctx SpecContext) {
values := fmt.Sprintf(`
auth:
url: https://github.com/example
secretName: existing-auth
controllerServiceAccount:
name: controller
namespace: arc-system
runner:
mode: %q
container:
env:
- {name: CUSTOM_MARKER, value: expected}
- {name: ACTIONS_RUNNER_POD_NAME, value: custom-pod}
- name: DOCKER_HOST
valueFrom:
secretKeyRef: {name: docker-host, key: address}
resources:
requests: {cpu: 250m, memory: 512Mi}
securityContext: {runAsUser: 1000, runAsGroup: 0, allowPrivilegeEscalation: false}
volumeMounts:
- {name: custom, mountPath: /custom}
pod:
spec:
volumes:
- {name: custom, emptyDir: {}}
`, mode)
want := corev1.RestartPolicyNever
if policy != "" {
values += " restartPolicy: " + policy + "\n"
want = corev1.RestartPolicy(policy)
}
var ars actionsv1alpha1.AutoscalingRunnerSet
Expect(yaml.UnmarshalStrict(renderAdmissionChart(ctx, "gha-runner-scale-set-experimental", "autoscalingrunnserset.yaml", namespace, values), &ars)).To(Succeed())
Expect(k8sClient.Create(ctx, &ars)).To(Succeed())
admitted := &actionsv1alpha1.AutoscalingRunnerSet{}
Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&ars), admitted)).To(Succeed())
Expect(admitted.Spec.ListenerConfig).To(Equal(ars.Spec.ListenerConfig))
cache := NewResourceCache()
builder := ResourceBuilder{ResourceCache: &cache}
ars.Annotations[runnerScaleSetIDAnnotationKey] = "1"
ers, err := builder.newEphemeralRunnerSet(&ars)
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient.Create(ctx, ers)).To(Succeed())
runner, err := builder.newEphemeralRunner(ers)
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient.Create(ctx, runner)).To(Succeed())
pod, err := builder.newEphemeralRunnerPod(runner, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "jit"}})
Expect(err).NotTo(HaveOccurred())
Expect(k8sClient.Create(ctx, &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{
Name: pod.Spec.ServiceAccountName, Namespace: namespace,
}})).To(Succeed())
Expect(k8sClient.Create(ctx, pod)).To(Succeed())
var live corev1.Pod
Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(pod), &live)).To(Succeed())
Expect(live.Spec.RestartPolicy).To(Equal(want))
Expect(ars.Spec.Template.Spec.RestartPolicy).To(Equal(want))
Expect(live.Spec.Containers[0].Env).To(ContainElement(corev1.EnvVar{Name: "CUSTOM_MARKER", Value: "expected"}))
Expect(live.Spec.Containers[0].Env).To(ContainElement(corev1.EnvVar{Name: "ACTIONS_RUNNER_POD_NAME", Value: "custom-pod"}))
Expect(live.Spec.Containers[0].Env).To(ContainElement(corev1.EnvVar{
Name: "DOCKER_HOST",
ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{
LocalObjectReference: corev1.LocalObjectReference{Name: "docker-host"}, Key: "address",
}},
}))
Expect(live.Spec.Containers[0].Resources.Requests.Cpu().String()).To(Equal("250m"))
Expect(live.Spec.Containers[0].VolumeMounts).To(ContainElement(corev1.VolumeMount{Name: "custom", MountPath: "/custom"}))
if mode == "dind" {
Expect(*live.Spec.InitContainers[1].RestartPolicy).To(Equal(corev1.ContainerRestartPolicyAlways))
}
})
}
}
for _, chart := range []string{"gha-runner-scale-set-controller", "gha-runner-scale-set-controller-experimental"} {
for _, address := range []string{"", ":8080", "127.0.0.1:8080", "[::]:8080", "localhost:8080", ":65535", "0"} {
It(fmt.Sprintf("admits %s metrics %q", chart, address), func(ctx SpecContext) {
values := ""
if address != "" {
values = fmt.Sprintf("metrics:\n controllerManagerAddr: %q\n listenerAddr: ':9090'\n listenerEndpoint: /metrics\n", address)
if strings.HasSuffix(chart, "-experimental") {
values = "controller:\n " + strings.ReplaceAll(strings.TrimSuffix(values, "\n"), "\n", "\n ") + "\n"
}
}
var deployment appsv1.Deployment
Expect(yaml.UnmarshalStrict(renderAdmissionChart(ctx, chart, "deployment.yaml", namespace, values), &deployment)).To(Succeed())
Expect(k8sClient.Create(ctx, &deployment)).To(Succeed())
var live appsv1.Deployment
Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&deployment), &live)).To(Succeed())
if address == "" || address == "0" {
Expect(live.Spec.Template.Spec.Containers[0].Ports).To(BeEmpty())
Expect(live.Spec.Template.Spec.Containers[0].Args).To(ContainElement("--metrics-addr=0"))
} else {
Expect(live.Spec.Template.Spec.Containers[0].Ports).To(HaveLen(1))
Expect(live.Spec.Template.Spec.Containers[0].Ports[0].ContainerPort).To(BeNumerically(">", 0))
Expect(live.Spec.Template.Spec.Containers[0].Args).To(ContainElement("--metrics-addr=" + address))
}
})
}
}
})