From 230e163c7758b8fd2de9003dfaccc5ccf1f016b6 Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Mon, 28 Sep 2026 15:26:36 +0200 Subject: [PATCH] Fix experimental charts and upgrade Helm tooling (#4687) --- .github/workflows/gha-publish-chart.yaml | 2 +- .github/workflows/gha-validate-chart.yaml | 18 +- .github/workflows/go.yaml | 9 + CONTRIBUTING.md | 12 + .../templates/_controller_template.tpl | 5 +- .../templates/_metrics.tpl | 87 +++++ .../controller_metrics_address_test.yaml | 48 +++ .../values.yaml | 4 +- .../templates/deployment.yaml | 8 +- .../tests/metrics_test.go | 136 +++++++ .../values.yaml | 2 + .../templates/_mode_dind.tpl | 25 +- .../templates/_mode_empty.tpl | 27 +- .../templates/_mode_kubernetes.tpl | 58 +-- .../templates/_runner_container.tpl | 82 ++++ .../templates/_secret_resolution.tpl | 16 + .../templates/autoscalingrunnserset.yaml | 17 +- .../templates/githubsecret.yaml | 2 +- ...ng_runner_set_container_contract_test.yaml | 62 +++ ...oscaling_runner_set_vault_config_test.yaml | 23 +- .../tests/contracts_test.go | 364 ++++++++++++++++++ .../values.yaml | 24 +- .../chart_contracts_test.go | 145 +++++++ 23 files changed, 1062 insertions(+), 114 deletions(-) create mode 100644 charts/gha-runner-scale-set-controller-experimental/templates/_metrics.tpl create mode 100644 charts/gha-runner-scale-set-controller-experimental/tests/controller_metrics_address_test.yaml create mode 100644 charts/gha-runner-scale-set-controller/tests/metrics_test.go create mode 100644 charts/gha-runner-scale-set-experimental/templates/_runner_container.tpl create mode 100644 charts/gha-runner-scale-set-experimental/templates/_secret_resolution.tpl create mode 100644 charts/gha-runner-scale-set-experimental/tests/autoscaling_runner_set_container_contract_test.yaml create mode 100644 charts/gha-runner-scale-set-experimental/tests/contracts_test.go create mode 100644 controllers/actions.github.com/chart_contracts_test.go diff --git a/.github/workflows/gha-publish-chart.yaml b/.github/workflows/gha-publish-chart.yaml index 85eab5c7..fe700835 100644 --- a/.github/workflows/gha-publish-chart.yaml +++ b/.github/workflows/gha-publish-chart.yaml @@ -40,7 +40,7 @@ on: default: false env: - HELM_VERSION: v3.8.0 + HELM_VERSION: v4.2.2 permissions: packages: write diff --git a/.github/workflows/gha-validate-chart.yaml b/.github/workflows/gha-validate-chart.yaml index cc7a4719..f61f896e 100644 --- a/.github/workflows/gha-validate-chart.yaml +++ b/.github/workflows/gha-validate-chart.yaml @@ -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/... diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index fde1be3b..ea2ba33c 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58dd75df..5c80849d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/charts/gha-runner-scale-set-controller-experimental/templates/_controller_template.tpl b/charts/gha-runner-scale-set-controller-experimental/templates/_controller_template.tpl index 1df465ef..4c03a8b1 100644 --- a/charts/gha-runner-scale-set-controller-experimental/templates/_controller_template.tpl +++ b/charts/gha-runner-scale-set-controller-experimental/templates/_controller_template.tpl @@ -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 . -}} diff --git a/charts/gha-runner-scale-set-controller-experimental/templates/_metrics.tpl b/charts/gha-runner-scale-set-controller-experimental/templates/_metrics.tpl new file mode 100644 index 00000000..3047c0fc --- /dev/null +++ b/charts/gha-runner-scale-set-controller-experimental/templates/_metrics.tpl @@ -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 -}} diff --git a/charts/gha-runner-scale-set-controller-experimental/tests/controller_metrics_address_test.yaml b/charts/gha-runner-scale-set-controller-experimental/tests/controller_metrics_address_test.yaml new file mode 100644 index 00000000..d9807044 --- /dev/null +++ b/charts/gha-runner-scale-set-controller-experimental/tests/controller_metrics_address_test.yaml @@ -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)' diff --git a/charts/gha-runner-scale-set-controller-experimental/values.yaml b/charts/gha-runner-scale-set-controller-experimental/values.yaml index d4a5cc80..86ddcad5 100644 --- a/charts/gha-runner-scale-set-controller-experimental/values.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/values.yaml @@ -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" - diff --git a/charts/gha-runner-scale-set-controller/templates/deployment.yaml b/charts/gha-runner-scale-set-controller/templates/deployment.yaml index f7f6257b..67c45de7 100644 --- a/charts/gha-runner-scale-set-controller/templates/deployment.yaml +++ b/charts/gha-runner-scale-set-controller/templates/deployment.yaml @@ -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 diff --git a/charts/gha-runner-scale-set-controller/tests/metrics_test.go b/charts/gha-runner-scale-set-controller/tests/metrics_test.go new file mode 100644 index 00000000..e2eae100 --- /dev/null +++ b/charts/gha-runner-scale-set-controller/tests/metrics_test.go @@ -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") +} diff --git a/charts/gha-runner-scale-set-controller/values.yaml b/charts/gha-runner-scale-set-controller/values.yaml index cc17c4ab..c96d3ba3 100644 --- a/charts/gha-runner-scale-set-controller/values.yaml +++ b/charts/gha-runner-scale-set-controller/values.yaml @@ -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" diff --git a/charts/gha-runner-scale-set-experimental/templates/_mode_dind.tpl b/charts/gha-runner-scale-set-experimental/templates/_mode_dind.tpl index a6e7ade5..fa1e8b7f 100644 --- a/charts/gha-runner-scale-set-experimental/templates/_mode_dind.tpl +++ b/charts/gha-runner-scale-set-experimental/templates/_mode_dind.tpl @@ -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 -}} diff --git a/charts/gha-runner-scale-set-experimental/templates/_mode_empty.tpl b/charts/gha-runner-scale-set-experimental/templates/_mode_empty.tpl index 16ba87f7..f0de7db8 100644 --- a/charts/gha-runner-scale-set-experimental/templates/_mode_empty.tpl +++ b/charts/gha-runner-scale-set-experimental/templates/_mode_empty.tpl @@ -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 }} \ No newline at end of file diff --git a/charts/gha-runner-scale-set-experimental/templates/_mode_kubernetes.tpl b/charts/gha-runner-scale-set-experimental/templates/_mode_kubernetes.tpl index 6589d01d..91119758 100644 --- a/charts/gha-runner-scale-set-experimental/templates/_mode_kubernetes.tpl +++ b/charts/gha-runner-scale-set-experimental/templates/_mode_kubernetes.tpl @@ -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" -}} diff --git a/charts/gha-runner-scale-set-experimental/templates/_runner_container.tpl b/charts/gha-runner-scale-set-experimental/templates/_runner_container.tpl new file mode 100644 index 00000000..488255cf --- /dev/null +++ b/charts/gha-runner-scale-set-experimental/templates/_runner_container.tpl @@ -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 -}} diff --git a/charts/gha-runner-scale-set-experimental/templates/_secret_resolution.tpl b/charts/gha-runner-scale-set-experimental/templates/_secret_resolution.tpl new file mode 100644 index 00000000..ed11bce4 --- /dev/null +++ b/charts/gha-runner-scale-set-experimental/templates/_secret_resolution.tpl @@ -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 -}} diff --git a/charts/gha-runner-scale-set-experimental/templates/autoscalingrunnserset.yaml b/charts/gha-runner-scale-set-experimental/templates/autoscalingrunnserset.yaml index 72c0be63..f97e892d 100644 --- a/charts/gha-runner-scale-set-experimental/templates/autoscalingrunnserset.yaml +++ b/charts/gha-runner-scale-set-experimental/templates/autoscalingrunnserset.yaml @@ -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 }} diff --git a/charts/gha-runner-scale-set-experimental/templates/githubsecret.yaml b/charts/gha-runner-scale-set-experimental/templates/githubsecret.yaml index 884b0bcb..49a1c306 100644 --- a/charts/gha-runner-scale-set-experimental/templates/githubsecret.yaml +++ b/charts/gha-runner-scale-set-experimental/templates/githubsecret.yaml @@ -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\"" -}} diff --git a/charts/gha-runner-scale-set-experimental/tests/autoscaling_runner_set_container_contract_test.yaml b/charts/gha-runner-scale-set-experimental/tests/autoscaling_runner_set_container_contract_test.yaml new file mode 100644 index 00000000..23484df1 --- /dev/null +++ b/charts/gha-runner-scale-set-experimental/tests/autoscaling_runner_set_container_contract_test.yaml @@ -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 diff --git a/charts/gha-runner-scale-set-experimental/tests/autoscaling_runner_set_vault_config_test.yaml b/charts/gha-runner-scale-set-experimental/tests/autoscaling_runner_set_vault_config_test.yaml index a5f93c40..7b4a31ff 100644 --- a/charts/gha-runner-scale-set-experimental/tests/autoscaling_runner_set_vault_config_test.yaml +++ b/charts/gha-runner-scale-set-experimental/tests/autoscaling_runner_set_vault_config_test.yaml @@ -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" diff --git a/charts/gha-runner-scale-set-experimental/tests/contracts_test.go b/charts/gha-runner-scale-set-experimental/tests/contracts_test.go new file mode 100644 index 00000000..5897a139 --- /dev/null +++ b/charts/gha-runner-scale-set-experimental/tests/contracts_test.go @@ -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") + }) + } + } +} diff --git a/charts/gha-runner-scale-set-experimental/values.yaml b/charts/gha-runner-scale-set-experimental/values.yaml index a818a036..406061c9 100644 --- a/charts/gha-runner-scale-set-experimental/values.yaml +++ b/charts/gha-runner-scale-set-experimental/values.yaml @@ -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"] diff --git a/controllers/actions.github.com/chart_contracts_test.go b/controllers/actions.github.com/chart_contracts_test.go new file mode 100644 index 00000000..48aa5295 --- /dev/null +++ b/controllers/actions.github.com/chart_contracts_test.go @@ -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)) + } + }) + } + } +})