diff --git a/charts/gha-runner-scale-set-experimental/templates/_helpers.tpl b/charts/gha-runner-scale-set-experimental/templates/_helpers.tpl index b3152851..2274431b 100644 --- a/charts/gha-runner-scale-set-experimental/templates/_helpers.tpl +++ b/charts/gha-runner-scale-set-experimental/templates/_helpers.tpl @@ -27,6 +27,106 @@ rendered as YAML booleans or numbers. {{- toYaml $out -}} {{- end }} +{{/* +Fail unless a value is absent or a mapping. Used to turn mis-typed metadata values into an +error that names the values path, instead of an opaque "range can't iterate over" further +down the render. +Expects a dict with "value" and "path". +*/}} +{{- define "assert-map" -}} +{{- $value := .value -}} +{{- if and (not (kindIs "invalid" $value)) (not (kindIs "map" $value)) -}} +{{- fail (printf "%s: must be a mapping, got %s" .path (kindOf $value)) -}} +{{- end -}} +{{- end }} + +{{/* +Fail unless a metadata value is a scalar. A map or list would otherwise be flattened by the +string coercion into Go's own formatting (`map[a:b]`), which is a syntactically valid but +meaningless label or annotation, and a null would become "". +Expects a dict with "value", "key", "kind" and "path". +*/}} +{{- define "assert-scalar" -}} +{{- $value := .value -}} +{{- if or (kindIs "map" $value) (kindIs "slice" $value) (kindIs "invalid" $value) -}} +{{- fail (printf "%s: invalid value for %s %q: must be a scalar, got %s. Quote the value if it is meant to be a string" .path .kind .key (kindOf $value)) -}} +{{- end -}} +{{- end }} + +{{/* +Validate a label or annotation key against the Kubernetes qualified name rules. +Expects a dict with "key", "kind" (label|annotation) and "path" (the values path used in the error message). +*/}} +{{- define "validate-metadata-key" -}} +{{- $key := .key -}} +{{- $kind := .kind -}} +{{- $path := .path -}} +{{- $parts := splitList "/" $key -}} +{{- $name := $key -}} +{{- if gt (len $parts) 2 -}} +{{- fail (printf "%s: invalid %s key %q: a qualified name must consist of an optional DNS subdomain prefix followed by a single '/'" $path $kind $key) -}} +{{- end -}} +{{- if eq (len $parts) 2 -}} +{{- $prefix := index $parts 0 -}} +{{- $name = index $parts 1 -}} +{{- if gt (len $prefix) 253 -}} +{{- fail (printf "%s: invalid %s key %q: the prefix %q must be a DNS subdomain of no more than 253 characters" $path $kind $key $prefix) -}} +{{- end -}} +{{- if not (regexMatch "^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$" $prefix) -}} +{{- fail (printf "%s: invalid %s key %q: the prefix %q must be a DNS subdomain, so it must consist of dot-separated segments of lowercase alphanumeric characters or '-', each starting and ending with an alphanumeric character" $path $kind $key $prefix) -}} +{{- end -}} +{{- end -}} +{{- if or (eq $name "") (gt (len $name) 63) (not (regexMatch "^[A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?$" $name)) -}} +{{- fail (printf "%s: invalid %s key %q: the name part must be no more than 63 characters, consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character" $path $kind $key) -}} +{{- end -}} +{{- end }} + +{{/* +Validate a metadata block (dict with optional "labels" and "annotations"). +Invalid runner pod labels are only rejected once the controller creates the runner pod, +which leaves the scale set without runners, so fail at render time instead. +Expects a dict with "metadata" and "path". +*/}} +{{- define "validate-metadata" -}} +{{- $path := .path -}} +{{- include "assert-map" (dict "value" .metadata "path" $path) -}} +{{- $metadata := .metadata | default dict -}} +{{- $labels := index $metadata "labels" -}} +{{- include "assert-map" (dict "value" $labels "path" (printf "%s.labels" $path)) -}} +{{- range $key, $value := ($labels | default dict) -}} +{{- include "validate-metadata-key" (dict "key" $key "kind" "label" "path" (printf "%s.labels" $path)) -}} +{{- include "assert-scalar" (dict "value" $value "key" $key "kind" "label" "path" (printf "%s.labels" $path)) -}} +{{- $rendered := include "metadata-value" $value -}} +{{- if gt (len $rendered) 63 -}} +{{- fail (printf "%s.labels: invalid value %q for label %q: a label value must be no more than 63 characters" $path $rendered $key) -}} +{{- end -}} +{{- if not (regexMatch "^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$" $rendered) -}} +{{- fail (printf "%s.labels: invalid value %q for label %q: a valid label value must be an empty string or consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character" $path $rendered $key) -}} +{{- end -}} +{{- end -}} +{{- $annotations := index $metadata "annotations" -}} +{{- include "assert-map" (dict "value" $annotations "path" (printf "%s.annotations" $path)) -}} +{{- range $key, $value := ($annotations | default dict) -}} +{{- include "validate-metadata-key" (dict "key" $key "kind" "annotation" "path" (printf "%s.annotations" $path)) -}} +{{- include "assert-scalar" (dict "value" $value "key" $key "kind" "annotation" "path" (printf "%s.annotations" $path)) -}} +{{- end -}} +{{- end }} + +{{/* +Validate every label and annotation map the chart can render onto resources it manages. +*/}} +{{- define "validate-all-metadata" -}} +{{- include "assert-map" (dict "value" .Values.resource "path" ".Values.resource") -}} +{{- range $resource, $config := (.Values.resource | default dict) }} +{{- include "assert-map" (dict "value" $config "path" (printf ".Values.resource.%s" $resource)) -}} +{{- include "validate-metadata" (dict "metadata" (index ($config | default dict) "metadata") "path" (printf ".Values.resource.%s.metadata" $resource)) -}} +{{- end }} +{{- include "assert-map" (dict "value" .Values.runner "path" ".Values.runner") -}} +{{- $runnerPod := index (.Values.runner | default dict) "pod" -}} +{{- include "assert-map" (dict "value" $runnerPod "path" ".Values.runner.pod") -}} +{{- include "validate-metadata" (dict "metadata" (index ($runnerPod | default dict) "metadata") "path" ".Values.runner.pod.metadata") -}} +{{- end }} + {{/* Create the labels for the GitHub auth secret. */}} diff --git a/charts/gha-runner-scale-set-experimental/templates/autoscalingrunnserset.yaml b/charts/gha-runner-scale-set-experimental/templates/autoscalingrunnserset.yaml index d42e59f2..72c0be63 100644 --- a/charts/gha-runner-scale-set-experimental/templates/autoscalingrunnserset.yaml +++ b/charts/gha-runner-scale-set-experimental/templates/autoscalingrunnserset.yaml @@ -1,4 +1,5 @@ {{- $runner := (.Values.runner | default dict) }} +{{- include "validate-all-metadata" . }} {{- $runnerMode := (index $runner "mode" | default "") }} {{- $kubeMode := (index $runner "kubernetesMode" | default dict) }} {{- $dind := (index $runner "dind" | default dict) }} diff --git a/charts/gha-runner-scale-set-experimental/templates/githubsecret.yaml b/charts/gha-runner-scale-set-experimental/templates/githubsecret.yaml index 0630891e..884b0bcb 100644 --- a/charts/gha-runner-scale-set-experimental/templates/githubsecret.yaml +++ b/charts/gha-runner-scale-set-experimental/templates/githubsecret.yaml @@ -1,3 +1,4 @@ +{{- include "validate-all-metadata" . }} {{- $usesKubernetesSecrets := or (not .Values.secretResolution) (eq .Values.secretResolution.type "kubernetes") -}} {{- if and (not $usesKubernetesSecrets) (empty .Values.auth.secretName) -}} diff --git a/charts/gha-runner-scale-set-experimental/templates/hook_extension.yaml b/charts/gha-runner-scale-set-experimental/templates/hook_extension.yaml index d9b4576a..b40e24c0 100644 --- a/charts/gha-runner-scale-set-experimental/templates/hook_extension.yaml +++ b/charts/gha-runner-scale-set-experimental/templates/hook_extension.yaml @@ -1,3 +1,4 @@ +{{- include "validate-all-metadata" . }} {{- $runner := (.Values.runner | default dict) -}} {{- $runnerMode := (index $runner "mode" | default "") -}} {{- $kubeMode := (index $runner "kubernetesMode" | default dict) -}} diff --git a/charts/gha-runner-scale-set-experimental/templates/kube_mode_role.yaml b/charts/gha-runner-scale-set-experimental/templates/kube_mode_role.yaml index acaef34f..168a0610 100644 --- a/charts/gha-runner-scale-set-experimental/templates/kube_mode_role.yaml +++ b/charts/gha-runner-scale-set-experimental/templates/kube_mode_role.yaml @@ -1,3 +1,4 @@ +{{- include "validate-all-metadata" . }} {{- $runner := (.Values.runner | default dict) -}} {{- $runnerMode := (index $runner "mode" | default "") -}} {{- $kubeMode := (index $runner "kubernetesMode" | default dict) -}} diff --git a/charts/gha-runner-scale-set-experimental/templates/kube_mode_role_binding.yaml b/charts/gha-runner-scale-set-experimental/templates/kube_mode_role_binding.yaml index c2ecc565..50334376 100644 --- a/charts/gha-runner-scale-set-experimental/templates/kube_mode_role_binding.yaml +++ b/charts/gha-runner-scale-set-experimental/templates/kube_mode_role_binding.yaml @@ -1,3 +1,4 @@ +{{- include "validate-all-metadata" . }} {{- $runner := (.Values.runner | default dict) -}} {{- $runnerMode := (index $runner "mode" | default "") -}} {{- $kubeMode := (index $runner "kubernetesMode" | default dict) -}} diff --git a/charts/gha-runner-scale-set-experimental/templates/kube_mode_serviceaccount.yaml b/charts/gha-runner-scale-set-experimental/templates/kube_mode_serviceaccount.yaml index 0e8b0b7a..1c9d65a1 100644 --- a/charts/gha-runner-scale-set-experimental/templates/kube_mode_serviceaccount.yaml +++ b/charts/gha-runner-scale-set-experimental/templates/kube_mode_serviceaccount.yaml @@ -1,3 +1,4 @@ +{{- include "validate-all-metadata" . }} {{- $runner := (.Values.runner | default dict) -}} {{- $runnerMode := (index $runner "mode" | default "") -}} {{- $kubeMode := (index $runner "kubernetesMode" | default dict) -}} diff --git a/charts/gha-runner-scale-set-experimental/templates/manager_role.yaml b/charts/gha-runner-scale-set-experimental/templates/manager_role.yaml index 2990ccc4..2ec8b3e7 100644 --- a/charts/gha-runner-scale-set-experimental/templates/manager_role.yaml +++ b/charts/gha-runner-scale-set-experimental/templates/manager_role.yaml @@ -1,3 +1,4 @@ +{{- include "validate-all-metadata" . }} apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: diff --git a/charts/gha-runner-scale-set-experimental/templates/manager_role_binding.yaml b/charts/gha-runner-scale-set-experimental/templates/manager_role_binding.yaml index 56094cec..9cf442ae 100644 --- a/charts/gha-runner-scale-set-experimental/templates/manager_role_binding.yaml +++ b/charts/gha-runner-scale-set-experimental/templates/manager_role_binding.yaml @@ -1,3 +1,4 @@ +{{- include "validate-all-metadata" . }} apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: diff --git a/charts/gha-runner-scale-set-experimental/templates/no_permission_serviceaccount.yaml b/charts/gha-runner-scale-set-experimental/templates/no_permission_serviceaccount.yaml index 6a2d53a4..1a5f99f4 100644 --- a/charts/gha-runner-scale-set-experimental/templates/no_permission_serviceaccount.yaml +++ b/charts/gha-runner-scale-set-experimental/templates/no_permission_serviceaccount.yaml @@ -1,3 +1,4 @@ +{{- include "validate-all-metadata" . }} {{- $runnerMode := (.Values.runner.mode | default "") -}} {{- if ne $runnerMode "kubernetes" -}} apiVersion: v1 diff --git a/charts/gha-runner-scale-set-experimental/tests/autoscaling_runner_set_metadata_validation_test.yaml b/charts/gha-runner-scale-set-experimental/tests/autoscaling_runner_set_metadata_validation_test.yaml new file mode 100644 index 00000000..9d02b695 --- /dev/null +++ b/charts/gha-runner-scale-set-experimental/tests/autoscaling_runner_set_metadata_validation_test.yaml @@ -0,0 +1,204 @@ +suite: "AutoscalingRunnerSet metadata validation" +templates: + - autoscalingrunnserset.yaml +tests: + - it: should fail when a runner pod label value is not a valid Kubernetes label value + set: + scaleset.name: "test" + auth.url: "https://github.com/org" + auth.githubToken: "gh_token12345" + controllerServiceAccount.name: "arc" + controllerServiceAccount.namespace: "arc-system" + runner: + pod: + metadata: + labels: + purpose: "“true”" + release: + name: "test-name" + namespace: "test-namespace" + asserts: + - failedTemplate: + errorMessage: '.Values.runner.pod.metadata.labels: invalid value "“true”" for label "purpose": a valid label value must be an empty string or consist of alphanumeric characters, ''-'', ''_'' or ''.'', and must start and end with an alphanumeric character' + + - it: should fail when a runner pod label key is not a valid qualified name + set: + scaleset.name: "test" + auth.url: "https://github.com/org" + auth.githubToken: "gh_token12345" + controllerServiceAccount.name: "arc" + controllerServiceAccount.namespace: "arc-system" + runner: + pod: + metadata: + labels: + "Invalid.Prefix/purpose": "ci" + release: + name: "test-name" + namespace: "test-namespace" + asserts: + - failedTemplate: + errorMessage: '.Values.runner.pod.metadata.labels: invalid label key "Invalid.Prefix/purpose": the prefix "Invalid.Prefix" must be a DNS subdomain, so it must consist of dot-separated segments of lowercase alphanumeric characters or ''-'', each starting and ending with an alphanumeric character' + + - it: should fail when a resource label value is not a valid Kubernetes label value + set: + scaleset.name: "test" + auth.url: "https://github.com/org" + auth.githubToken: "gh_token12345" + controllerServiceAccount.name: "arc" + controllerServiceAccount.namespace: "arc-system" + resource: + all: + metadata: + labels: + team: "not valid" + release: + name: "test-name" + namespace: "test-namespace" + asserts: + - failedTemplate: + errorMessage: '.Values.resource.all.metadata.labels: invalid value "not valid" for label "team": a valid label value must be an empty string or consist of alphanumeric characters, ''-'', ''_'' or ''.'', and must start and end with an alphanumeric character' + + - it: should fail when an annotation key is not a valid qualified name + set: + scaleset.name: "test" + auth.url: "https://github.com/org" + auth.githubToken: "gh_token12345" + controllerServiceAccount.name: "arc" + controllerServiceAccount.namespace: "arc-system" + resource: + all: + metadata: + annotations: + "invalid key": "value" + release: + name: "test-name" + namespace: "test-namespace" + asserts: + - failedTemplate: + errorMessage: '.Values.resource.all.metadata.annotations: invalid annotation key "invalid key": the name part must be no more than 63 characters, consist of alphanumeric characters, ''-'', ''_'' or ''.'', and must start and end with an alphanumeric character' + + - it: should render scalar label and annotation values as strings + set: + scaleset.name: "test" + auth.url: "https://github.com/org" + auth.githubToken: "gh_token12345" + controllerServiceAccount.name: "arc" + controllerServiceAccount.namespace: "arc-system" + resource: + ephemeralRunner: + metadata: + labels: + sync-wave: 1 + annotations: + enabled: true + runner: + pod: + metadata: + labels: + enabled: true + release: + name: "test-name" + namespace: "test-namespace" + asserts: + - equal: + path: spec.template.metadata.labels["enabled"] + value: "true" + - equal: + path: spec.ephemeralRunnerMetadata.labels["sync-wave"] + value: "1" + - equal: + path: spec.ephemeralRunnerMetadata.annotations["enabled"] + value: "true" + + - it: should fail when a metadata block is not a mapping + set: + scaleset.name: "test" + auth.url: "https://github.com/org" + auth.githubToken: "gh_token12345" + controllerServiceAccount.name: "arc" + controllerServiceAccount.namespace: "arc-system" + runner: + pod: + metadata: "oops" + release: + name: "test-name" + namespace: "test-namespace" + asserts: + - failedTemplate: + errorMessage: '.Values.runner.pod.metadata: must be a mapping, got string' + + - it: should fail when a labels block is not a mapping + set: + scaleset.name: "test" + auth.url: "https://github.com/org" + auth.githubToken: "gh_token12345" + controllerServiceAccount.name: "arc" + controllerServiceAccount.namespace: "arc-system" + resource: + all: + metadata: + labels: "oops" + release: + name: "test-name" + namespace: "test-namespace" + asserts: + - failedTemplate: + errorMessage: '.Values.resource.all.metadata.labels: must be a mapping, got string' + + - it: should fail when a resource entry is not a mapping + set: + scaleset.name: "test" + auth.url: "https://github.com/org" + auth.githubToken: "gh_token12345" + controllerServiceAccount.name: "arc" + controllerServiceAccount.namespace: "arc-system" + resource: + ephemeralRunner: "oops" + release: + name: "test-name" + namespace: "test-namespace" + asserts: + - failedTemplate: + errorMessage: '.Values.resource.ephemeralRunner: must be a mapping, got string' + + - it: should fail when a metadata value is a mapping instead of a scalar + set: + scaleset.name: "test" + auth.url: "https://github.com/org" + auth.githubToken: "gh_token12345" + controllerServiceAccount.name: "arc" + controllerServiceAccount.namespace: "arc-system" + runner: + pod: + metadata: + annotations: + nested: + inner: "value" + release: + name: "test-name" + namespace: "test-namespace" + asserts: + - failedTemplate: + errorMessage: '.Values.runner.pod.metadata.annotations: invalid value for annotation "nested": must be a scalar, got map. Quote the value if it is meant to be a string' + + - it: should accept a prefix segment longer than 63 characters, matching Kubernetes + set: + scaleset.name: "test" + auth.url: "https://github.com/org" + auth.githubToken: "gh_token12345" + controllerServiceAccount.name: "arc" + controllerServiceAccount.namespace: "arc-system" + runner: + pod: + metadata: + labels: + ? "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example.com/purpose" + : "yes" + release: + name: "test-name" + namespace: "test-namespace" + asserts: + - equal: + path: spec.template.metadata.labels["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example.com/purpose"] + value: "yes" diff --git a/charts/gha-runner-scale-set/templates/_helpers.tpl b/charts/gha-runner-scale-set/templates/_helpers.tpl index 295fe8fa..b37031be 100644 --- a/charts/gha-runner-scale-set/templates/_helpers.tpl +++ b/charts/gha-runner-scale-set/templates/_helpers.tpl @@ -83,6 +83,115 @@ rendered as YAML booleans or numbers. {{- toYaml $out -}} {{- end }} +{{/* +Fail unless a value is absent or a mapping. Used to turn mis-typed metadata values into an +error that names the values path, instead of an opaque "range can't iterate over" further +down the render. +Expects a dict with "value" and "path". +*/}} +{{- define "gha-runner-scale-set.assertMap" -}} +{{- $value := .value -}} +{{- if and (not (kindIs "invalid" $value)) (not (kindIs "map" $value)) -}} +{{- fail (printf "%s: must be a mapping, got %s" .path (kindOf $value)) -}} +{{- end -}} +{{- end }} + +{{/* +Validate a label or annotation key against the Kubernetes qualified name rules. +Expects a dict with "key", "kind" (label|annotation) and "path" (the values path used in the error message). +*/}} +{{- define "gha-runner-scale-set.validateMetadataKey" -}} +{{- $key := .key -}} +{{- $kind := .kind -}} +{{- $path := .path -}} +{{- $parts := splitList "/" $key -}} +{{- $name := $key -}} +{{- if gt (len $parts) 2 -}} +{{- fail (printf "%s: invalid %s key %q: a qualified name must consist of an optional DNS subdomain prefix followed by a single '/'" $path $kind $key) -}} +{{- end -}} +{{- if eq (len $parts) 2 -}} +{{- $prefix := index $parts 0 -}} +{{- $name = index $parts 1 -}} +{{- if gt (len $prefix) 253 -}} +{{- fail (printf "%s: invalid %s key %q: the prefix %q must be a DNS subdomain of no more than 253 characters" $path $kind $key $prefix) -}} +{{- end -}} +{{- if not (regexMatch "^[a-z0-9]([-a-z0-9]*[a-z0-9])?([.][a-z0-9]([-a-z0-9]*[a-z0-9])?)*$" $prefix) -}} +{{- fail (printf "%s: invalid %s key %q: the prefix %q must be a DNS subdomain, so it must consist of dot-separated segments of lowercase alphanumeric characters or '-', each starting and ending with an alphanumeric character" $path $kind $key $prefix) -}} +{{- end -}} +{{- end -}} +{{- if or (eq $name "") (gt (len $name) 63) (not (regexMatch "^[A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?$" $name)) -}} +{{- fail (printf "%s: invalid %s key %q: the name part must be no more than 63 characters, consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character" $path $kind $key) -}} +{{- end -}} +{{- end }} + +{{/* +Fail unless a metadata value is a scalar. A map or list would otherwise be flattened by the +string coercion into Go's own formatting (`map[a:b]`), which is a syntactically valid but +meaningless label or annotation, and a null would become "". +Expects a dict with "value", "key", "kind" and "path". +*/}} +{{- define "gha-runner-scale-set.assertScalar" -}} +{{- $value := .value -}} +{{- if or (kindIs "map" $value) (kindIs "slice" $value) (kindIs "invalid" $value) -}} +{{- fail (printf "%s: invalid value for %s %q: must be a scalar, got %s. Quote the value if it is meant to be a string" .path .kind .key (kindOf $value)) -}} +{{- end -}} +{{- end }} + +{{/* +Validate a map of labels. Invalid labels are only rejected once the controller creates the +runner pod, which leaves the scale set without runners, so fail at render time instead. +Expects a dict with "labels" and "path". +*/}} +{{- define "gha-runner-scale-set.validateLabels" -}} +{{- $path := .path -}} +{{- include "gha-runner-scale-set.assertMap" (dict "value" .labels "path" $path) -}} +{{- range $key, $value := (.labels | default dict) -}} +{{- include "gha-runner-scale-set.validateMetadataKey" (dict "key" $key "kind" "label" "path" $path) -}} +{{- include "gha-runner-scale-set.assertScalar" (dict "value" $value "key" $key "kind" "label" "path" $path) -}} +{{- $rendered := include "gha-runner-scale-set.metadataValue" $value -}} +{{- if gt (len $rendered) 63 -}} +{{- fail (printf "%s: invalid value %q for label %q: a label value must be no more than 63 characters" $path $rendered $key) -}} +{{- end -}} +{{- if not (regexMatch "^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$" $rendered) -}} +{{- fail (printf "%s: invalid value %q for label %q: a valid label value must be an empty string or consist of alphanumeric characters, '-', '_' or '.', and must start and end with an alphanumeric character" $path $rendered $key) -}} +{{- end -}} +{{- end -}} +{{- end }} + +{{/* +Validate a map of annotations. Annotation values are unconstrained, so only keys are validated. +Expects a dict with "annotations" and "path". +*/}} +{{- define "gha-runner-scale-set.validateAnnotations" -}} +{{- $path := .path -}} +{{- include "gha-runner-scale-set.assertMap" (dict "value" .annotations "path" $path) -}} +{{- range $key, $value := (.annotations | default dict) -}} +{{- include "gha-runner-scale-set.validateMetadataKey" (dict "key" $key "kind" "annotation" "path" $path) -}} +{{- include "gha-runner-scale-set.assertScalar" (dict "value" $value "key" $key "kind" "annotation" "path" $path) -}} +{{- end -}} +{{- end }} + +{{/* +Validate every label and annotation map the chart can render onto resources it manages. +*/}} +{{- define "gha-runner-scale-set.validateMetadata" -}} +{{- include "gha-runner-scale-set.validateLabels" (dict "labels" .Values.labels "path" ".Values.labels") -}} +{{- include "gha-runner-scale-set.validateAnnotations" (dict "annotations" .Values.annotations "path" ".Values.annotations") -}} +{{- include "gha-runner-scale-set.assertMap" (dict "value" .Values.template "path" ".Values.template") -}} +{{- $templateMetadata := index (.Values.template | default dict) "metadata" -}} +{{- include "gha-runner-scale-set.assertMap" (dict "value" $templateMetadata "path" ".Values.template.metadata") -}} +{{- $templateMetadata = $templateMetadata | default dict -}} +{{- include "gha-runner-scale-set.validateLabels" (dict "labels" (index $templateMetadata "labels") "path" ".Values.template.metadata.labels") -}} +{{- include "gha-runner-scale-set.validateAnnotations" (dict "annotations" (index $templateMetadata "annotations") "path" ".Values.template.metadata.annotations") -}} +{{- include "gha-runner-scale-set.assertMap" (dict "value" .Values.resourceMeta "path" ".Values.resourceMeta") -}} +{{- range $resource, $meta := (.Values.resourceMeta | default dict) }} +{{- include "gha-runner-scale-set.assertMap" (dict "value" $meta "path" (printf ".Values.resourceMeta.%s" $resource)) -}} +{{- $meta = $meta | default dict -}} +{{- include "gha-runner-scale-set.validateLabels" (dict "labels" (index $meta "labels") "path" (printf ".Values.resourceMeta.%s.labels" $resource)) -}} +{{- include "gha-runner-scale-set.validateAnnotations" (dict "annotations" (index $meta "annotations") "path" (printf ".Values.resourceMeta.%s.annotations" $resource)) -}} +{{- end }} +{{- end }} + {{/* Render a ResourceMeta block for AutoscalingRunnerSet spec fields. */}} diff --git a/charts/gha-runner-scale-set/templates/autoscalingrunnerset.yaml b/charts/gha-runner-scale-set/templates/autoscalingrunnerset.yaml index 494229c1..1de996d6 100644 --- a/charts/gha-runner-scale-set/templates/autoscalingrunnerset.yaml +++ b/charts/gha-runner-scale-set/templates/autoscalingrunnerset.yaml @@ -1,6 +1,7 @@ {{- $resourceMeta := default (dict) .Values.resourceMeta }} {{- $hasCustomResourceMeta := (and .Values.resourceMeta .Values.resourceMeta.autoscalingRunnerSet) }} {{- /* .Values.listenerConfig is validated by values.schema.json */ -}} +{{- include "gha-runner-scale-set.validateMetadata" . }} apiVersion: actions.github.com/v1alpha1 kind: AutoscalingRunnerSet metadata: diff --git a/charts/gha-runner-scale-set/templates/githubsecret.yaml b/charts/gha-runner-scale-set/templates/githubsecret.yaml index aa600997..509f5aa8 100644 --- a/charts/gha-runner-scale-set/templates/githubsecret.yaml +++ b/charts/gha-runner-scale-set/templates/githubsecret.yaml @@ -1,3 +1,4 @@ +{{- include "gha-runner-scale-set.validateMetadata" . }} {{- if not (kindIs "string" .Values.githubConfigSecret) }} {{- $hasCustomResourceMeta := (and .Values.resourceMeta .Values.resourceMeta.githubConfigSecret) }} apiVersion: v1 diff --git a/charts/gha-runner-scale-set/templates/kube_mode_role.yaml b/charts/gha-runner-scale-set/templates/kube_mode_role.yaml index 8af39efc..ef9743cf 100644 --- a/charts/gha-runner-scale-set/templates/kube_mode_role.yaml +++ b/charts/gha-runner-scale-set/templates/kube_mode_role.yaml @@ -1,3 +1,4 @@ +{{- include "gha-runner-scale-set.validateMetadata" . }} {{- $containerMode := .Values.containerMode }} {{- $hasCustomResourceMeta := (and .Values.resourceMeta .Values.resourceMeta.kubernetesModeRole) }} {{- if and (or (eq $containerMode.type "kubernetes") (eq $containerMode.type "kubernetes-novolume")) (not .Values.template.spec.serviceAccountName) }} diff --git a/charts/gha-runner-scale-set/templates/kube_mode_role_binding.yaml b/charts/gha-runner-scale-set/templates/kube_mode_role_binding.yaml index 85910d8d..9b7cd1cf 100644 --- a/charts/gha-runner-scale-set/templates/kube_mode_role_binding.yaml +++ b/charts/gha-runner-scale-set/templates/kube_mode_role_binding.yaml @@ -1,3 +1,4 @@ +{{- include "gha-runner-scale-set.validateMetadata" . }} {{- $containerMode := .Values.containerMode }} {{- $hasCustomResourceMeta := (and .Values.resourceMeta .Values.resourceMeta.kubernetesModeRoleBinding) }} {{- if and (or (eq $containerMode.type "kubernetes") (eq $containerMode.type "kubernetes-novolume")) (not .Values.template.spec.serviceAccountName) }} diff --git a/charts/gha-runner-scale-set/templates/kube_mode_serviceaccount.yaml b/charts/gha-runner-scale-set/templates/kube_mode_serviceaccount.yaml index d782c0eb..094897d3 100644 --- a/charts/gha-runner-scale-set/templates/kube_mode_serviceaccount.yaml +++ b/charts/gha-runner-scale-set/templates/kube_mode_serviceaccount.yaml @@ -1,3 +1,4 @@ +{{- include "gha-runner-scale-set.validateMetadata" . }} {{- $containerMode := .Values.containerMode }} {{- $hasCustomResourceMeta := (and .Values.resourceMeta .Values.resourceMeta.kubernetesModeServiceAccount) }} {{- if and (or (eq $containerMode.type "kubernetes") (eq $containerMode.type "kubernetes-novolume")) (not .Values.template.spec.serviceAccountName) }} diff --git a/charts/gha-runner-scale-set/templates/manager_role.yaml b/charts/gha-runner-scale-set/templates/manager_role.yaml index 5319b55b..59873cc2 100644 --- a/charts/gha-runner-scale-set/templates/manager_role.yaml +++ b/charts/gha-runner-scale-set/templates/manager_role.yaml @@ -1,3 +1,4 @@ +{{- include "gha-runner-scale-set.validateMetadata" . }} {{- $hasCustomResourceMeta := (and .Values.resourceMeta .Values.resourceMeta.managerRole) }} apiVersion: rbac.authorization.k8s.io/v1 kind: Role diff --git a/charts/gha-runner-scale-set/templates/manager_role_binding.yaml b/charts/gha-runner-scale-set/templates/manager_role_binding.yaml index dbc65138..45379ac9 100644 --- a/charts/gha-runner-scale-set/templates/manager_role_binding.yaml +++ b/charts/gha-runner-scale-set/templates/manager_role_binding.yaml @@ -1,3 +1,4 @@ +{{- include "gha-runner-scale-set.validateMetadata" . }} {{- $hasCustomResourceMeta := (and .Values.resourceMeta .Values.resourceMeta.managerRoleBinding) }} apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding diff --git a/charts/gha-runner-scale-set/templates/no_permission_serviceaccount.yaml b/charts/gha-runner-scale-set/templates/no_permission_serviceaccount.yaml index de766967..e44c4c60 100644 --- a/charts/gha-runner-scale-set/templates/no_permission_serviceaccount.yaml +++ b/charts/gha-runner-scale-set/templates/no_permission_serviceaccount.yaml @@ -1,3 +1,4 @@ +{{- include "gha-runner-scale-set.validateMetadata" . }} {{- $hasCustomResourceMeta := (and .Values.resourceMeta .Values.resourceMeta.noPermissionServiceAccount) }} {{- $containerMode := .Values.containerMode }} {{- if and (ne $containerMode.type "kubernetes") (ne $containerMode.type "kubernetes-novolume") (not .Values.template.spec.serviceAccountName) }} diff --git a/charts/gha-runner-scale-set/tests/template_test.go b/charts/gha-runner-scale-set/tests/template_test.go index 23bada3f..2032e8f1 100644 --- a/charts/gha-runner-scale-set/tests/template_test.go +++ b/charts/gha-runner-scale-set/tests/template_test.go @@ -2684,7 +2684,7 @@ func TestCustomLabels(t *testing.T) { "controllerServiceAccount.name": "arc", "containerMode.type": "kubernetes", "controllerServiceAccount.namespace": "arc-system", - `labels.argocd\.argoproj\.io/sync-wave`: `"1"`, + `labels.argocd\.argoproj\.io/sync-wave`: "1", `labels.app\.kubernetes\.io/part-of`: "no-override", // this shouldn't be overwritten "resourceMeta.autoscalingRunnerSet.labels.ars-custom": "ars-custom-value", "resourceMeta.githubConfigSecret.labels.gh-custom": "gh-custom-value", @@ -2708,7 +2708,7 @@ func TestCustomLabels(t *testing.T) { output := helm.RenderTemplateContext(t, t.Context(), options, helmChartPath, releaseName, []string{"templates/githubsecret.yaml"}) const targetLabel = "argocd.argoproj.io/sync-wave" - const wantCustomValue = `"1"` + const wantCustomValue = "1" const reservedLabel = "app.kubernetes.io/part-of" const wantReservedValue = "gha-rs" @@ -2783,7 +2783,7 @@ func TestCustomLabels(t *testing.T) { "githubConfigSecret.github_token": "gh_token12345", "controllerServiceAccount.name": "arc", "controllerServiceAccount.namespace": "arc-system", - `labels.argocd\.argoproj\.io/sync-wave`: `"1"`, + `labels.argocd\.argoproj\.io/sync-wave`: "1", "resourceMeta.noPermissionServiceAccount.labels.npsa-custom": "npsa-custom-value", }, KubectlOptions: k8s.NewKubectlOptions("", "", namespaceName), @@ -2815,7 +2815,7 @@ func TestCustomAnnotations(t *testing.T) { "containerMode.type": "kubernetes", "controllerServiceAccount.name": "arc", "controllerServiceAccount.namespace": "arc-system", - `annotations.argocd\.argoproj\.io/sync-wave`: `"1"`, + `annotations.argocd\.argoproj\.io/sync-wave`: "1", "resourceMeta.autoscalingRunnerSet.annotations.ars-custom": "ars-custom-value", "resourceMeta.githubConfigSecret.annotations.gh-custom": "gh-custom-value", "resourceMeta.kubernetesModeRole.annotations.kmr-custom": "kmr-custom-value", @@ -2836,7 +2836,7 @@ func TestCustomAnnotations(t *testing.T) { } const targetAnnotations = "argocd.argoproj.io/sync-wave" - const wantCustomValue = `"1"` + const wantCustomValue = "1" output := helm.RenderTemplateContext(t, t.Context(), options, helmChartPath, releaseName, []string{"templates/githubsecret.yaml"}) @@ -2904,7 +2904,7 @@ func TestCustomAnnotations(t *testing.T) { "githubConfigSecret.github_token": "gh_token12345", "controllerServiceAccount.name": "arc", "controllerServiceAccount.namespace": "arc-system", - `annotations.argocd\.argoproj\.io/sync-wave`: `"1"`, + `annotations.argocd\.argoproj\.io/sync-wave`: "1", "resourceMeta.noPermissionServiceAccount.annotations.npsa-custom": "npsa-custom-value", }, KubectlOptions: k8s.NewKubectlOptions("", "", namespaceName), @@ -3203,3 +3203,222 @@ func TestTemplateRenderedAutoScalingRunnerSet_ScalarMetadataValuesAreRenderedAsS assert.Equal(t, "true", githubSecret.Labels["chart-bool"]) assert.Equal(t, "1.5", githubSecret.Annotations["chart-float-annotation"]) } + +func TestTemplateRenderedAutoScalingRunnerSet_InvalidMetadataValidationError(t *testing.T) { + t.Parallel() + + helmChartPath, err := filepath.Abs("../../gha-runner-scale-set") + require.NoError(t, err) + + tt := map[string]struct { + key string + value string + expectedError string + }{ + "runner pod label with smart quotes": { + key: "template.metadata.labels.custom", + value: "\u201ctrue\u201d", + expectedError: `.Values.template.metadata.labels: invalid value "“true”" for label "custom"`, + }, + "runner pod label value too long": { + key: "template.metadata.labels.custom", + value: strings.Repeat("a", 64), + expectedError: `.Values.template.metadata.labels: invalid value "` + strings.Repeat("a", 64) + `" for label "custom": a label value must be no more than 63 characters`, + }, + "runner pod label key with invalid prefix": { + key: "template.metadata.labels.Invalid\\.Prefix/custom", + value: "value", + expectedError: `.Values.template.metadata.labels: invalid label key "Invalid.Prefix/custom"`, + }, + "runner pod annotation key invalid": { + key: "template.metadata.annotations.invalid key", + value: "value", + expectedError: `.Values.template.metadata.annotations: invalid annotation key "invalid key"`, + }, + "chart level label invalid": { + key: "labels.custom", + value: "not valid", + expectedError: `.Values.labels: invalid value "not valid" for label "custom"`, + }, + "resource meta label invalid": { + key: "resourceMeta.ephemeralRunner.labels.custom", + value: "not valid", + expectedError: `.Values.resourceMeta.ephemeralRunner.labels: invalid value "not valid" for label "custom"`, + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + t.Parallel() + + releaseName := "test-runners" + namespaceName := "test-" + strings.ToLower(random.UniqueID()) + + options := &helm.Options{ + Logger: logger.Discard, + SetValues: map[string]string{ + "githubConfigUrl": "https://github.com/actions", + "githubConfigSecret.github_token": "gh_token12345", + "controllerServiceAccount.name": "arc", + "controllerServiceAccount.namespace": "arc-system", + tc.key: tc.value, + }, + KubectlOptions: k8s.NewKubectlOptions("", "", namespaceName), + } + + _, err := helm.RenderTemplateContextE(t, t.Context(), options, helmChartPath, releaseName, []string{"templates/autoscalingrunnerset.yaml"}) + require.Error(t, err) + assert.ErrorContains(t, err, tc.expectedError) + }) + } +} + +func TestTemplateRenderedAutoScalingRunnerSet_ValidMetadataIsAccepted(t *testing.T) { + t.Parallel() + + helmChartPath, err := filepath.Abs("../../gha-runner-scale-set") + require.NoError(t, err) + + releaseName := "test-runners" + namespaceName := "test-" + strings.ToLower(random.UniqueID()) + + options := &helm.Options{ + Logger: logger.Discard, + SetValues: map[string]string{ + "githubConfigUrl": "https://github.com/actions", + "githubConfigSecret.github_token": "gh_token12345", + "controllerServiceAccount.name": "arc", + "controllerServiceAccount.namespace": "arc-system", + "template.metadata.labels.custom": "true", + "template.metadata.labels.example\\.com/custom": "value_1.0-rc", + "template.metadata.labels.empty": "", + "template.metadata.annotations.example\\.com/an": "any value is allowed: ✅", + }, + KubectlOptions: k8s.NewKubectlOptions("", "", namespaceName), + } + + output := helm.RenderTemplateContext(t, t.Context(), options, helmChartPath, releaseName, []string{"templates/autoscalingrunnerset.yaml"}) + + var autoscalingRunnerSet v1alpha1.AutoscalingRunnerSet + helm.UnmarshalK8SYaml(t, output, &autoscalingRunnerSet) + + assert.Equal(t, "true", autoscalingRunnerSet.Spec.Template.Labels["custom"]) + assert.Equal(t, "value_1.0-rc", autoscalingRunnerSet.Spec.Template.Labels["example.com/custom"]) + assert.Equal(t, "", autoscalingRunnerSet.Spec.Template.Labels["empty"]) + assert.Equal(t, "any value is allowed: ✅", autoscalingRunnerSet.Spec.Template.Annotations["example.com/an"]) +} + +func TestTemplateRenderedAutoScalingRunnerSet_NonMapMetadataValidationError(t *testing.T) { + t.Parallel() + + helmChartPath, err := filepath.Abs("../../gha-runner-scale-set") + require.NoError(t, err) + + tt := map[string]struct { + key string + value string + expectedError string + }{ + "chart labels is not a map": { + key: "labels", + value: "oops", + expectedError: ".Values.labels: must be a mapping, got string", + }, + "runner pod labels is not a map": { + key: "template.metadata.labels", + value: "oops", + expectedError: ".Values.template.metadata.labels: must be a mapping, got string", + }, + "resourceMeta entry is not a map": { + key: "resourceMeta.githubConfigSecret", + value: "oops", + expectedError: ".Values.resourceMeta.githubConfigSecret: must be a mapping, got string", + }, + } + + for name, tc := range tt { + t.Run(name, func(t *testing.T) { + t.Parallel() + + releaseName := "test-runners" + namespaceName := "test-" + strings.ToLower(random.UniqueID()) + + options := &helm.Options{ + Logger: logger.Discard, + SetValues: map[string]string{ + "githubConfigUrl": "https://github.com/actions", + "githubConfigSecret.github_token": "gh_token12345", + "controllerServiceAccount.name": "arc", + "controllerServiceAccount.namespace": "arc-system", + tc.key: tc.value, + }, + KubectlOptions: k8s.NewKubectlOptions("", "", namespaceName), + } + + _, err := helm.RenderTemplateContextE(t, t.Context(), options, helmChartPath, releaseName, []string{"templates/autoscalingrunnerset.yaml"}) + require.Error(t, err) + assert.ErrorContains(t, err, tc.expectedError) + }) + } +} + +func TestTemplateRenderedAutoScalingRunnerSet_NonScalarMetadataValueValidationError(t *testing.T) { + t.Parallel() + + helmChartPath, err := filepath.Abs("../../gha-runner-scale-set") + require.NoError(t, err) + + releaseName := "test-runners" + namespaceName := "test-" + strings.ToLower(random.UniqueID()) + + options := &helm.Options{ + Logger: logger.Discard, + SetValues: map[string]string{ + "githubConfigUrl": "https://github.com/actions", + "githubConfigSecret.github_token": "gh_token12345", + "controllerServiceAccount.name": "arc", + "controllerServiceAccount.namespace": "arc-system", + }, + SetJsonValues: map[string]string{ + "template.metadata.annotations": `{"nested":{"inner":"value"}}`, + }, + KubectlOptions: k8s.NewKubectlOptions("", "", namespaceName), + } + + _, err = helm.RenderTemplateContextE(t, t.Context(), options, helmChartPath, releaseName, []string{"templates/autoscalingrunnerset.yaml"}) + require.Error(t, err) + assert.Contains(t, err.Error(), `.Values.template.metadata.annotations: invalid value for annotation "nested": must be a scalar, got map`) +} + +// Kubernetes only bounds a label key prefix at 253 characters in total, so the chart must +// not impose the stricter per-segment 63 character limit that applies to DNS labels. +func TestTemplateRenderedAutoScalingRunnerSet_LongPrefixSegmentIsAccepted(t *testing.T) { + t.Parallel() + + helmChartPath, err := filepath.Abs("../../gha-runner-scale-set") + require.NoError(t, err) + + releaseName := "test-runners" + namespaceName := "test-" + strings.ToLower(random.UniqueID()) + + key := strings.Repeat("a", 64) + ".example.com/purpose" + + options := &helm.Options{ + Logger: logger.Discard, + SetValues: map[string]string{ + "githubConfigUrl": "https://github.com/actions", + "githubConfigSecret.github_token": "gh_token12345", + "controllerServiceAccount.name": "arc", + "controllerServiceAccount.namespace": "arc-system", + "template.metadata.labels." + strings.ReplaceAll(key, ".", `\.`): "yes", + }, + KubectlOptions: k8s.NewKubectlOptions("", "", namespaceName), + } + + output := helm.RenderTemplateContext(t, t.Context(), options, helmChartPath, releaseName, []string{"templates/autoscalingrunnerset.yaml"}) + + var autoscalingRunnerSet v1alpha1.AutoscalingRunnerSet + helm.UnmarshalK8SYaml(t, output, &autoscalingRunnerSet) + + assert.Equal(t, "yes", autoscalingRunnerSet.Spec.Template.Labels[key]) +}