From 276bb1ca3aef1d25b3f369d3f03cc117efebe322 Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Fri, 12 Jun 2026 17:10:10 +0200 Subject: [PATCH 01/31] Fix ephemeral runner set status update --- .../templates/_mode_kubernetes.tpl | 5 ++++- .../actions.github.com/ephemeralrunnerset_controller.go | 3 +-- 2 files changed, 5 insertions(+), 3 deletions(-) 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 6589d01d1c..90800e80d3 100644 --- a/charts/gha-runner-scale-set-experimental/templates/_mode_kubernetes.tpl +++ b/charts/gha-runner-scale-set-experimental/templates/_mode_kubernetes.tpl @@ -82,7 +82,10 @@ volumeMounts: subPath: extension readOnly: true {{- end }} - {{ include "githubServerTLS.volumeMountItem" (dict "root" $ "existingVolumeMounts" (list)) | nindent 2 }} + {{- with .Values.runner.container.volumeMounts }} + {{- toYaml . | nindent 2 }} + {{- end }} + {{ include "githubServerTLS.volumeMountItem" (dict "root" $ "existingVolumeMounts" (.Values.runner.container.volumeMounts | default list)) | nindent 2 }} {{- end }} {{- define "runner-mode-kubernetes.pod-volumes" -}} diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller.go b/controllers/actions.github.com/ephemeralrunnerset_controller.go index 919a46419f..092b93f6e0 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller.go @@ -245,7 +245,6 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R } func (r *EphemeralRunnerSetReconciler) updateStatus(ctx context.Context, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, state *ephemeralRunnersByState, log logr.Logger) error { - original := ephemeralRunnerSet.DeepCopy() var phase v1alpha1.EphemeralRunnerSetPhase switch { case len(state.outdated) > 0: @@ -262,7 +261,7 @@ func (r *EphemeralRunnerSetReconciler) updateStatus(ctx context.Context, ephemer // Update the status if needed. if ephemeralRunnerSet.Status != desiredStatus { ephemeralRunnerSet.Status = desiredStatus - if err := r.Status().Patch(ctx, ephemeralRunnerSet, client.MergeFrom(original)); err != nil { + if err := r.Status().Update(ctx, ephemeralRunnerSet); err != nil { log.Error(err, "Failed to update EphemeralRunnerSet status") return err } From 390d6e289d15b9aa3560b9cc56bc1363b472f749 Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Sat, 13 Jun 2026 01:13:55 +0200 Subject: [PATCH 02/31] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../templates/_mode_kubernetes.tpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 90800e80d3..672c8495d4 100644 --- a/charts/gha-runner-scale-set-experimental/templates/_mode_kubernetes.tpl +++ b/charts/gha-runner-scale-set-experimental/templates/_mode_kubernetes.tpl @@ -85,7 +85,7 @@ volumeMounts: {{- with .Values.runner.container.volumeMounts }} {{- toYaml . | nindent 2 }} {{- end }} - {{ include "githubServerTLS.volumeMountItem" (dict "root" $ "existingVolumeMounts" (.Values.runner.container.volumeMounts | default list)) | nindent 2 }} + {{ include "githubServerTLS.volumeMountItem" (dict "root" $ "existingVolumeMounts" (.Values.runner.container.volumeMounts | default (list))) | nindent 2 }} {{- end }} {{- define "runner-mode-kubernetes.pod-volumes" -}} From 5f974df2decfe3d8dc8a890cd5d3dee7f0fc743b Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Mon, 15 Jun 2026 10:41:30 +0200 Subject: [PATCH 03/31] Renam Url to URL --- .../v1alpha1/autoscalingrunnerset_types.go | 10 +++++----- apis/actions.github.com/v1alpha1/proxy_config_test.go | 8 ++++---- charts/gha-runner-scale-set/tests/template_test.go | 4 ++-- .../autoscalinglistener_controller_test.go | 4 ++-- .../autoscalingrunnerset_controller_test.go | 4 ++-- .../ephemeralrunner_controller_test.go | 6 +++--- .../ephemeralrunnerset_controller_test.go | 6 +++--- .../secretresolver/secret_resolver.go | 8 ++++---- 8 files changed, 25 insertions(+), 25 deletions(-) diff --git a/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go b/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go index dd1bce7fa1..21aa5e9059 100644 --- a/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go +++ b/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go @@ -182,9 +182,9 @@ func (c *ProxyConfig) ToHTTPProxyConfig(secretFetcher func(string) (*corev1.Secr } if c.HTTP != nil { - u, err := url.Parse(c.HTTP.Url) + u, err := url.Parse(c.HTTP.URL) if err != nil { - return nil, fmt.Errorf("failed to parse proxy http url %q: %w", c.HTTP.Url, err) + return nil, fmt.Errorf("failed to parse proxy http url %q: %w", c.HTTP.URL, err) } if c.HTTP.CredentialSecretRef != "" { @@ -207,9 +207,9 @@ func (c *ProxyConfig) ToHTTPProxyConfig(secretFetcher func(string) (*corev1.Secr } if c.HTTPS != nil { - u, err := url.Parse(c.HTTPS.Url) + u, err := url.Parse(c.HTTPS.URL) if err != nil { - return nil, fmt.Errorf("failed to parse proxy https url %q: %w", c.HTTPS.Url, err) + return nil, fmt.Errorf("failed to parse proxy https url %q: %w", c.HTTPS.URL, err) } if c.HTTPS.CredentialSecretRef != "" { @@ -263,7 +263,7 @@ func (c *ProxyConfig) ProxyFunc(secretFetcher func(string) (*corev1.Secret, erro type ProxyServerConfig struct { // Required - Url string `json:"url,omitempty"` + URL string `json:"url,omitempty"` // +optional CredentialSecretRef string `json:"credentialSecretRef,omitempty"` diff --git a/apis/actions.github.com/v1alpha1/proxy_config_test.go b/apis/actions.github.com/v1alpha1/proxy_config_test.go index 9291cde4e0..0357e5e268 100644 --- a/apis/actions.github.com/v1alpha1/proxy_config_test.go +++ b/apis/actions.github.com/v1alpha1/proxy_config_test.go @@ -14,11 +14,11 @@ import ( func TestProxyConfig_ToSecret(t *testing.T) { config := &v1alpha1.ProxyConfig{ HTTP: &v1alpha1.ProxyServerConfig{ - Url: "http://proxy.example.com:8080", + URL: "http://proxy.example.com:8080", CredentialSecretRef: "my-secret", }, HTTPS: &v1alpha1.ProxyServerConfig{ - Url: "https://proxy.example.com:8080", + URL: "https://proxy.example.com:8080", CredentialSecretRef: "my-secret", }, NoProxy: []string{ @@ -48,11 +48,11 @@ func TestProxyConfig_ToSecret(t *testing.T) { func TestProxyConfig_ProxyFunc(t *testing.T) { config := &v1alpha1.ProxyConfig{ HTTP: &v1alpha1.ProxyServerConfig{ - Url: "http://proxy.example.com:8080", + URL: "http://proxy.example.com:8080", CredentialSecretRef: "my-secret", }, HTTPS: &v1alpha1.ProxyServerConfig{ - Url: "https://proxy.example.com:8080", + URL: "https://proxy.example.com:8080", CredentialSecretRef: "my-secret", }, NoProxy: []string{ diff --git a/charts/gha-runner-scale-set/tests/template_test.go b/charts/gha-runner-scale-set/tests/template_test.go index e9aa4c9bcb..8c74b956ed 100644 --- a/charts/gha-runner-scale-set/tests/template_test.go +++ b/charts/gha-runner-scale-set/tests/template_test.go @@ -1365,11 +1365,11 @@ func TestTemplateRenderedWithProxy(t *testing.T) { require.NotNil(t, ars.Spec.Proxy) require.NotNil(t, ars.Spec.Proxy.HTTP) - assert.Equal(t, "http://proxy.example.com", ars.Spec.Proxy.HTTP.Url) + assert.Equal(t, "http://proxy.example.com", ars.Spec.Proxy.HTTP.URL) assert.Equal(t, "http-secret", ars.Spec.Proxy.HTTP.CredentialSecretRef) require.NotNil(t, ars.Spec.Proxy.HTTPS) - assert.Equal(t, "https://proxy.example.com", ars.Spec.Proxy.HTTPS.Url) + assert.Equal(t, "https://proxy.example.com", ars.Spec.Proxy.HTTPS.URL) assert.Equal(t, "https-secret", ars.Spec.Proxy.HTTPS.CredentialSecretRef) require.NotNil(t, ars.Spec.Proxy.NoProxy) diff --git a/controllers/actions.github.com/autoscalinglistener_controller_test.go b/controllers/actions.github.com/autoscalinglistener_controller_test.go index d48d613e6c..8ec0ebc27c 100644 --- a/controllers/actions.github.com/autoscalinglistener_controller_test.go +++ b/controllers/actions.github.com/autoscalinglistener_controller_test.go @@ -954,11 +954,11 @@ var _ = Describe("Test AutoScalingListener controller with proxy", func() { proxy := &v1alpha1.ProxyConfig{ HTTP: &v1alpha1.ProxyServerConfig{ - Url: "http://localhost:8080", + URL: "http://localhost:8080", CredentialSecretRef: "proxy-credentials", }, HTTPS: &v1alpha1.ProxyServerConfig{ - Url: "https://localhost:8443", + URL: "https://localhost:8443", CredentialSecretRef: "proxy-credentials", }, NoProxy: []string{ diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go index 11a03dc482..28d44ad1b2 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go @@ -1241,7 +1241,7 @@ var _ = Describe("Test client optional configuration", Ordered, func() { RunnerGroup: "testgroup", Proxy: &v1alpha1.ProxyConfig{ HTTP: &v1alpha1.ProxyServerConfig{ - Url: proxy.URL, + URL: proxy.URL, }, }, Template: corev1.PodTemplateSpec{ @@ -1319,7 +1319,7 @@ var _ = Describe("Test client optional configuration", Ordered, func() { RunnerGroup: "testgroup", Proxy: &v1alpha1.ProxyConfig{ HTTP: &v1alpha1.ProxyServerConfig{ - Url: "http://test:password@" + proxy.Listener.Addr().String(), + URL: "http://test:password@" + proxy.Listener.Addr().String(), CredentialSecretRef: "proxy-credentials", }, }, diff --git a/controllers/actions.github.com/ephemeralrunner_controller_test.go b/controllers/actions.github.com/ephemeralrunner_controller_test.go index 80c27134a7..74aafb71a1 100644 --- a/controllers/actions.github.com/ephemeralrunner_controller_test.go +++ b/controllers/actions.github.com/ephemeralrunner_controller_test.go @@ -1367,7 +1367,7 @@ var _ = Describe("EphemeralRunner", func() { ephemeralRunner.Spec.GitHubConfigURL = "http://example.com/org/repo" ephemeralRunner.Spec.Proxy = &v1alpha1.ProxyConfig{ HTTP: &v1alpha1.ProxyServerConfig{ - Url: proxy.URL, + URL: proxy.URL, CredentialSecretRef: "proxy-credentials", }, } @@ -1388,10 +1388,10 @@ var _ = Describe("EphemeralRunner", func() { ephemeralRunner := newExampleRunner("test-runner", autoScalingNS.Name, configSecret.Name) ephemeralRunner.Spec.Proxy = &v1alpha1.ProxyConfig{ HTTP: &v1alpha1.ProxyServerConfig{ - Url: "http://proxy.example.com:8080", + URL: "http://proxy.example.com:8080", }, HTTPS: &v1alpha1.ProxyServerConfig{ - Url: "http://proxy.example.com:8080", + URL: "http://proxy.example.com:8080", }, NoProxy: []string{"example.com"}, } diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go index 76cf98ab88..4526a5b0ff 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go @@ -1546,11 +1546,11 @@ var _ = Describe("Test EphemeralRunnerSet controller with proxy settings", func( RunnerScaleSetID: 100, Proxy: &v1alpha1.ProxyConfig{ HTTP: &v1alpha1.ProxyServerConfig{ - Url: "http://proxy.example.com", + URL: "http://proxy.example.com", CredentialSecretRef: secretCredentials.Name, }, HTTPS: &v1alpha1.ProxyServerConfig{ - Url: "https://proxy.example.com", + URL: "https://proxy.example.com", CredentialSecretRef: secretCredentials.Name, }, NoProxy: []string{"example.com", "example.org"}, @@ -1729,7 +1729,7 @@ var _ = Describe("Test EphemeralRunnerSet controller with proxy settings", func( RunnerScaleSetID: 100, Proxy: &v1alpha1.ProxyConfig{ HTTP: &v1alpha1.ProxyServerConfig{ - Url: proxy.URL, + URL: proxy.URL, CredentialSecretRef: "proxy-credentials", }, }, diff --git a/controllers/actions.github.com/secretresolver/secret_resolver.go b/controllers/actions.github.com/secretresolver/secret_resolver.go index 5f3e95612d..b9f9d9ccba 100644 --- a/controllers/actions.github.com/secretresolver/secret_resolver.go +++ b/controllers/actions.github.com/secretresolver/secret_resolver.go @@ -85,9 +85,9 @@ func (sr *SecretResolver) GetActionsService(ctx context.Context, obj object.Acti } if proxy.HTTP != nil { - u, err := url.Parse(proxy.HTTP.Url) + u, err := url.Parse(proxy.HTTP.URL) if err != nil { - return nil, fmt.Errorf("failed to parse proxy http url %q: %w", proxy.HTTP.Url, err) + return nil, fmt.Errorf("failed to parse proxy http url %q: %w", proxy.HTTP.URL, err) } if ref := proxy.HTTP.CredentialSecretRef; ref != "" { @@ -101,9 +101,9 @@ func (sr *SecretResolver) GetActionsService(ctx context.Context, obj object.Acti } if proxy.HTTPS != nil { - u, err := url.Parse(proxy.HTTPS.Url) + u, err := url.Parse(proxy.HTTPS.URL) if err != nil { - return nil, fmt.Errorf("failed to parse proxy https url %q: %w", proxy.HTTPS.Url, err) + return nil, fmt.Errorf("failed to parse proxy https url %q: %w", proxy.HTTPS.URL, err) } if ref := proxy.HTTPS.CredentialSecretRef; ref != "" { From ae9b446095e9439ef4d90af5690ae5cefb8fae52 Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Mon, 15 Jun 2026 12:21:17 +0200 Subject: [PATCH 04/31] wip --- .../v1alpha1/autoscalingrunnerset_types.go | 13 ++++---- ...tions.github.com_autoscalinglisteners.yaml | 30 +++++++++++++++---- ...ions.github.com_autoscalingrunnersets.yaml | 30 ++++++++++++------- .../actions.github.com_ephemeralrunners.yaml | 19 ++++++++---- ...ctions.github.com_ephemeralrunnersets.yaml | 19 ++++++++---- ...tions.github.com_autoscalinglisteners.yaml | 30 +++++++++++++++---- ...ions.github.com_autoscalingrunnersets.yaml | 30 ++++++++++++------- .../actions.github.com_ephemeralrunners.yaml | 19 ++++++++---- ...ctions.github.com_ephemeralrunnersets.yaml | 19 ++++++++---- ...tions.github.com_autoscalinglisteners.yaml | 30 +++++++++++++++---- ...ions.github.com_autoscalingrunnersets.yaml | 30 ++++++++++++------- .../actions.github.com_ephemeralrunners.yaml | 19 ++++++++---- ...ctions.github.com_ephemeralrunnersets.yaml | 19 ++++++++---- 13 files changed, 213 insertions(+), 94 deletions(-) diff --git a/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go b/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go index 21aa5e9059..7435e88c45 100644 --- a/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go +++ b/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go @@ -40,8 +40,7 @@ import ( // +kubebuilder:printcolumn:JSONPath=".status.phase",name=Phase,type=string // +kubebuilder:printcolumn:JSONPath=".status.pendingEphemeralRunners",name=Pending Runners,type=integer // +kubebuilder:printcolumn:JSONPath=".status.runningEphemeralRunners",name=Running Runners,type=integer -// +kubebuilder:printcolumn:JSONPath=".status.finishedEphemeralRunners",name=Finished Runners,type=integer -// +kubebuilder:printcolumn:JSONPath=".status.deletingEphemeralRunners",name=Deleting Runners,type=integer +// +kubebuilder:printcolumn:JSONPath=".status.failedEphemeralRunners",name=Failed Runners,type=integer // AutoscalingRunnerSet is the Schema for the autoscalingrunnersets API type AutoscalingRunnerSet struct { @@ -115,16 +114,16 @@ type AutoscalingRunnerSetSpec struct { EphemeralRunnerConfigSecretMetadata *ResourceMeta `json:"ephemeralRunnerConfigSecretMetadata,omitempty"` // +optional - // +kubebuilder:validation:Minimum:=0 + // +kubebuilder:validation:Minimum=0 MaxRunners *int `json:"maxRunners,omitempty"` // +optional - // +kubebuilder:validation:Minimum:=0 + // +kubebuilder:validation:Minimum=0 MinRunners *int `json:"minRunners,omitempty"` } type TLSConfig struct { - // Required + // +required CertificateFrom *TLSCertificateSource `json:"certificateFrom,omitempty"` } @@ -161,7 +160,7 @@ func (c *TLSConfig) ToCertPool(keyFetcher func(name, key string) ([]byte, error) } type TLSCertificateSource struct { - // Required + // +required ConfigMapKeyRef *corev1.ConfigMapKeySelector `json:"configMapKeyRef,omitempty"` } @@ -262,7 +261,7 @@ func (c *ProxyConfig) ProxyFunc(secretFetcher func(string) (*corev1.Secret, erro } type ProxyServerConfig struct { - // Required + // +required URL string `json:"url,omitempty"` // +optional diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalinglisteners.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalinglisteners.yaml index 29cc9bb4bc..756cb9662d 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalinglisteners.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalinglisteners.yaml @@ -76,10 +76,9 @@ spec: githubServerTLS: properties: certificateFrom: - description: Required properties: configMapKeyRef: - description: Required + description: Selects a key from a ConfigMap. properties: key: description: The key to select. @@ -101,7 +100,11 @@ spec: - key type: object x-kubernetes-map-type: atomic + required: + - configMapKeyRef type: object + required: + - certificateFrom type: object image: type: string @@ -184,16 +187,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: @@ -8772,16 +8777,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: @@ -8794,6 +8801,17 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object + required: + - autoscalingRunnerSetName + - autoscalingRunnerSetNamespace + - ephemeralRunnerSetName + - githubConfigSecret + - githubConfigUrl + - image + - imagePullSecrets + - maxRunners + - minRunners + - runnerScaleSetId type: object status: description: AutoscalingListenerStatus defines the observed state of AutoscalingListener diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml index a2dfd46d9f..083c3d175d 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml @@ -33,11 +33,8 @@ spec: - jsonPath: .status.runningEphemeralRunners name: Running Runners type: integer - - jsonPath: .status.finishedEphemeralRunners - name: Finished Runners - type: integer - - jsonPath: .status.deletingEphemeralRunners - name: Deleting Runners + - jsonPath: .status.failedEphemeralRunners + name: Failed Runners type: integer name: v1alpha1 schema: @@ -119,10 +116,9 @@ spec: githubServerTLS: properties: certificateFrom: - description: Required properties: configMapKeyRef: - description: Required + description: Selects a key from a ConfigMap. properties: key: description: The key to select. @@ -143,7 +139,11 @@ spec: - key type: object x-kubernetes-map-type: atomic + required: + - configMapKeyRef type: object + required: + - certificateFrom type: object listenerConfigSecretMetadata: description: ResourceMeta carries metadata common to all internal resources @@ -8364,16 +8364,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: @@ -16515,16 +16517,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: @@ -16537,6 +16541,10 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object + required: + - githubConfigSecret + - githubConfigUrl + - template type: object status: description: AutoscalingRunnerSetStatus defines the observed state of AutoscalingRunnerSet diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunners.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunners.yaml index a174d751d4..8248263e7d 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunners.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunners.yaml @@ -89,10 +89,9 @@ spec: githubServerTLS: properties: certificateFrom: - description: Required properties: configMapKeyRef: - description: Required + description: Selects a key from a ConfigMap. properties: key: description: The key to select. @@ -113,7 +112,11 @@ spec: - key type: object x-kubernetes-map-type: atomic + required: + - configMapKeyRef type: object + required: + - certificateFrom type: object metadata: description: |- @@ -144,16 +147,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: @@ -8268,16 +8273,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml index a6f9758bfa..70b21c9ad6 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml @@ -83,10 +83,9 @@ spec: githubServerTLS: properties: certificateFrom: - description: Required properties: configMapKeyRef: - description: Required + description: Selects a key from a ConfigMap. properties: key: description: The key to select. @@ -107,7 +106,11 @@ spec: - key type: object x-kubernetes-map-type: atomic + required: + - configMapKeyRef type: object + required: + - certificateFrom type: object metadata: description: |- @@ -138,16 +141,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: @@ -8262,16 +8267,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalinglisteners.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalinglisteners.yaml index 29cc9bb4bc..756cb9662d 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalinglisteners.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalinglisteners.yaml @@ -76,10 +76,9 @@ spec: githubServerTLS: properties: certificateFrom: - description: Required properties: configMapKeyRef: - description: Required + description: Selects a key from a ConfigMap. properties: key: description: The key to select. @@ -101,7 +100,11 @@ spec: - key type: object x-kubernetes-map-type: atomic + required: + - configMapKeyRef type: object + required: + - certificateFrom type: object image: type: string @@ -184,16 +187,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: @@ -8772,16 +8777,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: @@ -8794,6 +8801,17 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object + required: + - autoscalingRunnerSetName + - autoscalingRunnerSetNamespace + - ephemeralRunnerSetName + - githubConfigSecret + - githubConfigUrl + - image + - imagePullSecrets + - maxRunners + - minRunners + - runnerScaleSetId type: object status: description: AutoscalingListenerStatus defines the observed state of AutoscalingListener diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml index a2dfd46d9f..083c3d175d 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml @@ -33,11 +33,8 @@ spec: - jsonPath: .status.runningEphemeralRunners name: Running Runners type: integer - - jsonPath: .status.finishedEphemeralRunners - name: Finished Runners - type: integer - - jsonPath: .status.deletingEphemeralRunners - name: Deleting Runners + - jsonPath: .status.failedEphemeralRunners + name: Failed Runners type: integer name: v1alpha1 schema: @@ -119,10 +116,9 @@ spec: githubServerTLS: properties: certificateFrom: - description: Required properties: configMapKeyRef: - description: Required + description: Selects a key from a ConfigMap. properties: key: description: The key to select. @@ -143,7 +139,11 @@ spec: - key type: object x-kubernetes-map-type: atomic + required: + - configMapKeyRef type: object + required: + - certificateFrom type: object listenerConfigSecretMetadata: description: ResourceMeta carries metadata common to all internal resources @@ -8364,16 +8364,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: @@ -16515,16 +16517,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: @@ -16537,6 +16541,10 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object + required: + - githubConfigSecret + - githubConfigUrl + - template type: object status: description: AutoscalingRunnerSetStatus defines the observed state of AutoscalingRunnerSet diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunners.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunners.yaml index a174d751d4..8248263e7d 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunners.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunners.yaml @@ -89,10 +89,9 @@ spec: githubServerTLS: properties: certificateFrom: - description: Required properties: configMapKeyRef: - description: Required + description: Selects a key from a ConfigMap. properties: key: description: The key to select. @@ -113,7 +112,11 @@ spec: - key type: object x-kubernetes-map-type: atomic + required: + - configMapKeyRef type: object + required: + - certificateFrom type: object metadata: description: |- @@ -144,16 +147,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: @@ -8268,16 +8273,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml index a6f9758bfa..70b21c9ad6 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml @@ -83,10 +83,9 @@ spec: githubServerTLS: properties: certificateFrom: - description: Required properties: configMapKeyRef: - description: Required + description: Selects a key from a ConfigMap. properties: key: description: The key to select. @@ -107,7 +106,11 @@ spec: - key type: object x-kubernetes-map-type: atomic + required: + - configMapKeyRef type: object + required: + - certificateFrom type: object metadata: description: |- @@ -138,16 +141,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: @@ -8262,16 +8267,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: diff --git a/config/crd/bases/actions.github.com_autoscalinglisteners.yaml b/config/crd/bases/actions.github.com_autoscalinglisteners.yaml index 29cc9bb4bc..756cb9662d 100644 --- a/config/crd/bases/actions.github.com_autoscalinglisteners.yaml +++ b/config/crd/bases/actions.github.com_autoscalinglisteners.yaml @@ -76,10 +76,9 @@ spec: githubServerTLS: properties: certificateFrom: - description: Required properties: configMapKeyRef: - description: Required + description: Selects a key from a ConfigMap. properties: key: description: The key to select. @@ -101,7 +100,11 @@ spec: - key type: object x-kubernetes-map-type: atomic + required: + - configMapKeyRef type: object + required: + - certificateFrom type: object image: type: string @@ -184,16 +187,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: @@ -8772,16 +8777,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: @@ -8794,6 +8801,17 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object + required: + - autoscalingRunnerSetName + - autoscalingRunnerSetNamespace + - ephemeralRunnerSetName + - githubConfigSecret + - githubConfigUrl + - image + - imagePullSecrets + - maxRunners + - minRunners + - runnerScaleSetId type: object status: description: AutoscalingListenerStatus defines the observed state of AutoscalingListener diff --git a/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml b/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml index a2dfd46d9f..083c3d175d 100644 --- a/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml +++ b/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml @@ -33,11 +33,8 @@ spec: - jsonPath: .status.runningEphemeralRunners name: Running Runners type: integer - - jsonPath: .status.finishedEphemeralRunners - name: Finished Runners - type: integer - - jsonPath: .status.deletingEphemeralRunners - name: Deleting Runners + - jsonPath: .status.failedEphemeralRunners + name: Failed Runners type: integer name: v1alpha1 schema: @@ -119,10 +116,9 @@ spec: githubServerTLS: properties: certificateFrom: - description: Required properties: configMapKeyRef: - description: Required + description: Selects a key from a ConfigMap. properties: key: description: The key to select. @@ -143,7 +139,11 @@ spec: - key type: object x-kubernetes-map-type: atomic + required: + - configMapKeyRef type: object + required: + - certificateFrom type: object listenerConfigSecretMetadata: description: ResourceMeta carries metadata common to all internal resources @@ -8364,16 +8364,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: @@ -16515,16 +16517,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: @@ -16537,6 +16541,10 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object + required: + - githubConfigSecret + - githubConfigUrl + - template type: object status: description: AutoscalingRunnerSetStatus defines the observed state of AutoscalingRunnerSet diff --git a/config/crd/bases/actions.github.com_ephemeralrunners.yaml b/config/crd/bases/actions.github.com_ephemeralrunners.yaml index a174d751d4..8248263e7d 100644 --- a/config/crd/bases/actions.github.com_ephemeralrunners.yaml +++ b/config/crd/bases/actions.github.com_ephemeralrunners.yaml @@ -89,10 +89,9 @@ spec: githubServerTLS: properties: certificateFrom: - description: Required properties: configMapKeyRef: - description: Required + description: Selects a key from a ConfigMap. properties: key: description: The key to select. @@ -113,7 +112,11 @@ spec: - key type: object x-kubernetes-map-type: atomic + required: + - configMapKeyRef type: object + required: + - certificateFrom type: object metadata: description: |- @@ -144,16 +147,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: @@ -8268,16 +8273,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: diff --git a/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml b/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml index a6f9758bfa..70b21c9ad6 100644 --- a/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml +++ b/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml @@ -83,10 +83,9 @@ spec: githubServerTLS: properties: certificateFrom: - description: Required properties: configMapKeyRef: - description: Required + description: Selects a key from a ConfigMap. properties: key: description: The key to select. @@ -107,7 +106,11 @@ spec: - key type: object x-kubernetes-map-type: atomic + required: + - configMapKeyRef type: object + required: + - certificateFrom type: object metadata: description: |- @@ -138,16 +141,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: @@ -8262,16 +8267,18 @@ spec: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object https: properties: credentialSecretRef: type: string url: - description: Required type: string + required: + - url type: object noProxy: items: From 34ef44ea4c8301c0f3ebaed1dccb98cc7a161479 Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Mon, 15 Jun 2026 12:41:07 +0200 Subject: [PATCH 05/31] wip --- .../crds/actions.github.com_autoscalinglisteners.yaml | 2 +- .../crds/actions.github.com_autoscalinglisteners.yaml | 2 +- config/crd/bases/actions.github.com_autoscalinglisteners.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalinglisteners.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalinglisteners.yaml index 756cb9662d..9265298660 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalinglisteners.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalinglisteners.yaml @@ -232,6 +232,7 @@ spec: type: object type: object runnerScaleSetId: + minimum: 1 type: integer serviceAccountMetadata: description: ResourceMeta carries metadata common to all internal @@ -8808,7 +8809,6 @@ spec: - githubConfigSecret - githubConfigUrl - image - - imagePullSecrets - maxRunners - minRunners - runnerScaleSetId diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalinglisteners.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalinglisteners.yaml index 756cb9662d..9265298660 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalinglisteners.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalinglisteners.yaml @@ -232,6 +232,7 @@ spec: type: object type: object runnerScaleSetId: + minimum: 1 type: integer serviceAccountMetadata: description: ResourceMeta carries metadata common to all internal @@ -8808,7 +8809,6 @@ spec: - githubConfigSecret - githubConfigUrl - image - - imagePullSecrets - maxRunners - minRunners - runnerScaleSetId diff --git a/config/crd/bases/actions.github.com_autoscalinglisteners.yaml b/config/crd/bases/actions.github.com_autoscalinglisteners.yaml index 756cb9662d..9265298660 100644 --- a/config/crd/bases/actions.github.com_autoscalinglisteners.yaml +++ b/config/crd/bases/actions.github.com_autoscalinglisteners.yaml @@ -232,6 +232,7 @@ spec: type: object type: object runnerScaleSetId: + minimum: 1 type: integer serviceAccountMetadata: description: ResourceMeta carries metadata common to all internal @@ -8808,7 +8809,6 @@ spec: - githubConfigSecret - githubConfigUrl - image - - imagePullSecrets - maxRunners - minRunners - runnerScaleSetId From 7a59fea6a90e72215bb1783618a58b74302fc1d8 Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Mon, 15 Jun 2026 16:22:15 +0200 Subject: [PATCH 06/31] Fix ephemeral runner set annotation propagation --- .../templates/manager_role.yaml | 2 + .../templates/manager_role.yaml | 2 + .../autoscalinglistener_controller.go | 14 +-- .../autoscalingrunnerset_controller.go | 10 +- .../autoscalingrunnerset_controller_test.go | 8 +- .../ephemeralrunnerset_controller.go | 6 +- .../actions.github.com/resourcebuilder.go | 105 ++++++++++-------- .../resourcebuilder_test.go | 8 +- 8 files changed, 86 insertions(+), 69 deletions(-) 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 2990ccc49f..a5c9a258ca 100644 --- a/charts/gha-runner-scale-set-experimental/templates/manager_role.yaml +++ b/charts/gha-runner-scale-set-experimental/templates/manager_role.yaml @@ -17,6 +17,8 @@ rules: verbs: - create - delete + - update + - patch - get - apiGroups: - "" diff --git a/charts/gha-runner-scale-set/templates/manager_role.yaml b/charts/gha-runner-scale-set/templates/manager_role.yaml index bbf9279999..88ca5a3f76 100644 --- a/charts/gha-runner-scale-set/templates/manager_role.yaml +++ b/charts/gha-runner-scale-set/templates/manager_role.yaml @@ -41,6 +41,8 @@ rules: verbs: - create - delete + - update + - patch - get - apiGroups: - "" diff --git a/controllers/actions.github.com/autoscalinglistener_controller.go b/controllers/actions.github.com/autoscalinglistener_controller.go index c0aa81114c..3b61a3baab 100644 --- a/controllers/actions.github.com/autoscalinglistener_controller.go +++ b/controllers/actions.github.com/autoscalinglistener_controller.go @@ -165,7 +165,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. desiredLabels := r.filterAndMergeLabels(serviceAccount.Labels, desiredServiceAccount.Labels) labelsModified := !maps.Equal(serviceAccount.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(serviceAccount.Annotations, desiredServiceAccount.Annotations) + desiredAnnotations := r.filterAndMergeAnnotations(serviceAccount.Annotations, desiredServiceAccount.Annotations) annotationsModified := !maps.Equal(serviceAccount.Annotations, desiredAnnotations) if labelsModified || annotationsModified { updatedServiceAccount := serviceAccount.DeepCopy() @@ -208,7 +208,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. desiredRole := r.newScaleSetListenerRole(&autoscalingListener) desiredLabels := r.filterAndMergeLabels(listenerRole.Labels, desiredRole.Labels) labelsModified := !maps.Equal(listenerRole.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(listenerRole.Annotations, desiredRole.Annotations) + desiredAnnotations := r.filterAndMergeAnnotations(listenerRole.Annotations, desiredRole.Annotations) annotationsModified := !maps.Equal(listenerRole.Annotations, desiredAnnotations) rulesModified := !reflect.DeepEqual(listenerRole.Rules, desiredRole.Rules) if labelsModified || annotationsModified || rulesModified { @@ -250,7 +250,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. ) desiredLabels := r.filterAndMergeLabels(listenerRoleBinding.Labels, desiredRoleBinding.Labels) labelsModified := !maps.Equal(listenerRoleBinding.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(listenerRoleBinding.Annotations, desiredRoleBinding.Annotations) + desiredAnnotations := r.filterAndMergeAnnotations(listenerRoleBinding.Annotations, desiredRoleBinding.Annotations) annotationsModified := !maps.Equal(listenerRoleBinding.Annotations, desiredAnnotations) if labelsModified || annotationsModified { updatedRoleBinding := listenerRoleBinding.DeepCopy() @@ -305,7 +305,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. } desiredLabels := r.filterAndMergeLabels(proxySecret.Labels, desiredListenerProxy.Labels) labelsModified := !maps.Equal(proxySecret.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(proxySecret.Annotations, desiredListenerProxy.Annotations) + desiredAnnotations := r.filterAndMergeAnnotations(proxySecret.Annotations, desiredListenerProxy.Annotations) annotationsModified := !maps.Equal(proxySecret.Annotations, desiredAnnotations) if labelsModified || annotationsModified { updatedProxySecret := proxySecret.DeepCopy() @@ -391,7 +391,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. } desiredLabels := r.filterAndMergeLabels(listenerConfigSecret.Labels, desiredSecret.Labels) labelsModified := !maps.Equal(listenerConfigSecret.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(listenerConfigSecret.Annotations, desiredSecret.Annotations) + desiredAnnotations := r.filterAndMergeAnnotations(listenerConfigSecret.Annotations, desiredSecret.Annotations) annotationsModified := !maps.Equal(listenerConfigSecret.Annotations, desiredAnnotations) if labelsModified || annotationsModified { @@ -462,7 +462,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, err } - shouldReCreate := desiredPod.Annotations[annotationKeyIntegrityHash] != listenerPod.Annotations[annotationKeyIntegrityHash] + shouldReCreate := desiredPod.Annotations[AnnotationKeyIntegrityHash] != listenerPod.Annotations[AnnotationKeyIntegrityHash] if shouldReCreate { log.Info("Listener pod dependency changed, recreating listener pod") if err := r.deleteListenerPod(ctx, &autoscalingListener, &listenerPod, log); err != nil { @@ -475,7 +475,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. desiredLabels := r.filterAndMergeLabels(listenerPod.Labels, desiredPod.Labels) labelsModified := !maps.Equal(listenerPod.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(listenerPod.Annotations, desiredPod.Annotations) + desiredAnnotations := r.filterAndMergeAnnotations(listenerPod.Annotations, desiredPod.Annotations) annotationsModified := !maps.Equal(listenerPod.Annotations, desiredAnnotations) if labelsModified || annotationsModified { diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller.go b/controllers/actions.github.com/autoscalingrunnerset_controller.go index e48f75ff15..1e1e2bb44c 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller.go @@ -142,13 +142,13 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl } // Something has changed, we need to re-apply the pending phase and change hash annotation to trigger the update of runner scale set and listener. - if targetHash := autoscalingRunnerSet.Hash(); autoscalingRunnerSet.Annotations[annotationKeyIntegrityHash] != targetHash { + if targetHash := autoscalingRunnerSet.Hash(); autoscalingRunnerSet.Annotations[AnnotationKeyIntegrityHash] != targetHash { // TODO: apply the version label original := autoscalingRunnerSet.DeepCopy() if autoscalingRunnerSet.Annotations == nil { autoscalingRunnerSet.Annotations = map[string]string{} } - autoscalingRunnerSet.Annotations[annotationKeyIntegrityHash] = targetHash + autoscalingRunnerSet.Annotations[AnnotationKeyIntegrityHash] = targetHash if err := r.Patch(ctx, &autoscalingRunnerSet, client.MergeFrom(original)); err != nil { log.Error(err, "Failed to update autoscaling runner set with new change hash and pending phase") return ctrl.Result{}, err @@ -291,12 +291,12 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl return ctrl.Result{}, nil } - if ephemeralRunnerSet.Annotations[annotationKeyIntegrityHash] != desired.Annotations[annotationKeyIntegrityHash] { + if ephemeralRunnerSet.Annotations[AnnotationKeyIntegrityHash] != desired.Annotations[AnnotationKeyIntegrityHash] { original := ephemeralRunnerSet.DeepCopy() ephemeralRunnerSet.Spec.EphemeralRunnerMetadata = desired.Spec.EphemeralRunnerMetadata ephemeralRunnerSet.Spec.EphemeralRunnerSpec = desired.Spec.EphemeralRunnerSpec ephemeralRunnerSet.Labels = r.filterAndMergeLabels(ephemeralRunnerSet.Labels, desired.Labels) - ephemeralRunnerSet.Annotations = r.mergeAnnotations(ephemeralRunnerSet.Annotations, desired.Annotations) + ephemeralRunnerSet.Annotations = r.filterAndMergeAnnotations(ephemeralRunnerSet.Annotations, desired.Annotations) log.Info("Updating ephemeral runner set spec to match the desired spec") if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original)); err != nil { @@ -315,7 +315,7 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl if ephemeralRunnerLabelsModified || ephemeralRunnerAnnotationsModified || ephemeralRunnerMetadataModified { original := ephemeralRunnerSet.DeepCopy() ephemeralRunnerSet.Labels = r.filterAndMergeLabels(ephemeralRunnerSet.Labels, desired.Labels) - ephemeralRunnerSet.Annotations = r.mergeAnnotations(ephemeralRunnerSet.Annotations, desired.Annotations) + ephemeralRunnerSet.Annotations = r.filterAndMergeAnnotations(ephemeralRunnerSet.Annotations, desired.Annotations) ephemeralRunnerSet.Spec.EphemeralRunnerMetadata = desired.Spec.EphemeralRunnerMetadata log.Info("Updating ephemeral runner set metadata to match desired labels and annotations") if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original)); err != nil { diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go index 28d44ad1b2..d67832718d 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go @@ -478,7 +478,7 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { autoscalingRunnerSetTestInterval, ).Should(Succeed(), "EphemeralRunnerSet should be created") originalRunnerSetUID := runnerSet.UID - originalRunnerSetHash := runnerSet.Annotations[annotationKeyIntegrityHash] + originalRunnerSetHash := runnerSet.Annotations[AnnotationKeyIntegrityHash] patched := autoscalingRunnerSet.DeepCopy() patched.Spec.Template.Spec.Containers[0].Image = "ghcr.io/actions/runner:updated" @@ -492,7 +492,7 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { g.Expect(err).NotTo(HaveOccurred(), "failed to get EphemeralRunnerSet") g.Expect(current.UID).To(Equal(originalRunnerSetUID), "EphemeralRunnerSet should be updated in place") g.Expect(current.Spec.EphemeralRunnerSpec.PodTemplateSpec.Spec.Containers[0].Image).To(Equal("ghcr.io/actions/runner:updated")) - g.Expect(current.Annotations[annotationKeyIntegrityHash]).NotTo(Equal(originalRunnerSetHash), "EphemeralRunnerSet spec hash should change") + g.Expect(current.Annotations[AnnotationKeyIntegrityHash]).NotTo(Equal(originalRunnerSetHash), "EphemeralRunnerSet spec hash should change") }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, @@ -531,7 +531,7 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { autoscalingRunnerSetTestInterval, ).Should(Succeed(), "EphemeralRunnerSet should be created") originalRunnerSetUID := runnerSet.UID - originalRunnerSetHash := runnerSet.Annotations[annotationKeyIntegrityHash] + originalRunnerSetHash := runnerSet.Annotations[AnnotationKeyIntegrityHash] patched := autoscalingRunnerSet.DeepCopy() max := 20 @@ -557,7 +557,7 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, current) g.Expect(err).NotTo(HaveOccurred(), "failed to get EphemeralRunnerSet") g.Expect(current.UID).To(Equal(originalRunnerSetUID), "EphemeralRunnerSet should not be recreated") - g.Expect(current.Annotations[annotationKeyIntegrityHash]).To(Equal(originalRunnerSetHash), "EphemeralRunnerSet spec should not change") + g.Expect(current.Annotations[AnnotationKeyIntegrityHash]).To(Equal(originalRunnerSetHash), "EphemeralRunnerSet spec should not change") }, time.Second*5, autoscalingRunnerSetTestInterval, diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller.go b/controllers/actions.github.com/ephemeralrunnerset_controller.go index 092b93f6e0..a6cb4d27ba 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller.go @@ -135,7 +135,7 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R // If hash spec has changed, delete idle ephemeral runners // in order to apply the change to the runners that did not yet receive a job. ephemeralRunnerIntegrityHash := ephemeralRunnerSetIntegrityHash(&ephemeralRunnerSet) - if ephemeralRunnerSet.Annotations[annotationKeyIntegrityHash] != ephemeralRunnerIntegrityHash { + if ephemeralRunnerSet.Annotations[AnnotationKeyIntegrityHash] != ephemeralRunnerIntegrityHash { log.Info("EphemeralRunnerSpec has changed, deleting idle ephemeral runners to apply the new spec") if _, err := r.cleanUpEphemeralRunners(ctx, &ephemeralRunnerSet, log); err != nil { log.Error(err, "Failed to clean up EphemeralRunners") @@ -152,7 +152,7 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R if ephemeralRunnerSet.Annotations == nil { ephemeralRunnerSet.Annotations = make(map[string]string) } - ephemeralRunnerSet.Annotations[annotationKeyIntegrityHash] = ephemeralRunnerIntegrityHash + ephemeralRunnerSet.Annotations[AnnotationKeyIntegrityHash] = ephemeralRunnerIntegrityHash if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original)); err != nil { log.Error(err, "Failed to update ephemeral runner set with new spec hash") return ctrl.Result{}, err @@ -467,7 +467,7 @@ func (r *EphemeralRunnerSetReconciler) reconcileEphemeralRunnerSetProxySecret(ct dataModified := !maps.EqualFunc(proxySecret.Data, desiredRunnerSetProxy.Data, bytes.Equal) desiredLabels := r.filterAndMergeLabels(proxySecret.Labels, desiredRunnerSetProxy.Labels) labelsModified := !maps.Equal(proxySecret.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(proxySecret.Annotations, desiredRunnerSetProxy.Annotations) + desiredAnnotations := r.filterAndMergeAnnotations(proxySecret.Annotations, desiredRunnerSetProxy.Annotations) annotationsModified := !maps.Equal(proxySecret.Annotations, desiredAnnotations) if dataModified || labelsModified || annotationsModified { updatedProxySecret := proxySecret.DeepCopy() diff --git a/controllers/actions.github.com/resourcebuilder.go b/controllers/actions.github.com/resourcebuilder.go index fd456b56f1..7c50a092e8 100644 --- a/controllers/actions.github.com/resourcebuilder.go +++ b/controllers/actions.github.com/resourcebuilder.go @@ -46,14 +46,14 @@ var commonLabelKeys = [...]string{ LabelKeyGitHubRepository, } -// annotationKeyIntegrityHash is used as a hash of the important fields +// AnnotationKeyIntegrityHash is used as a hash of the important fields // of each resource to determine if more drastic action should be taken. // // For example, annotations/labels are not something that should modify // the behavior of a resource, while the change in spec is. Therefore, // the spec hash should contain the spec fields in order to determine // modifications. -const annotationKeyIntegrityHash = "actions.github.com/integrity-hash" +const AnnotationKeyIntegrityHash = "actions.github.com/integrity-hash" const labelValueKubernetesPartOf = "gha-runner-scale-set" @@ -165,12 +165,12 @@ func (b *ResourceBuilder) newAutoscalingListener(autoscalingRunnerSet *v1alpha1. } annotations := map[string]string{ - annotationKeyIntegrityHash: spec.Hash(), + AnnotationKeyIntegrityHash: spec.Hash(), } if autoscalingRunnerSet.Spec.AutoscalingListenerMetadata != nil { labels = b.filterAndMergeLabels(autoscalingRunnerSet.Spec.AutoscalingListenerMetadata.Labels, labels) - annotations = b.mergeAnnotations(autoscalingRunnerSet.Spec.AutoscalingListenerMetadata.Annotations, annotations) + annotations = b.filterAndMergeAnnotations(autoscalingRunnerSet.Spec.AutoscalingListenerMetadata.Annotations, annotations) } autoscalingListener := &v1alpha1.AutoscalingListener{ @@ -278,7 +278,7 @@ func (b *ResourceBuilder) newScaleSetListenerConfig(autoscalingListener *v1alpha }, } - desiredSecret.Annotations[annotationKeyIntegrityHash] = scaleSetListenerConfigIntegrityHash(desiredSecret) + desiredSecret.Annotations[AnnotationKeyIntegrityHash] = scaleSetListenerConfigIntegrityHash(desiredSecret) if err := b.setControllerReference(autoscalingListener, desiredSecret); err != nil { return nil, fmt.Errorf("failed to set controller reference for listener config secret: %w", err) @@ -426,7 +426,7 @@ func (b *ResourceBuilder) newScaleSetListenerPod( Spec: podSpec, } - newRunnerScaleSetListenerPod.Annotations[annotationKeyIntegrityHash] = scaleSetListenerPodIntegrity( + newRunnerScaleSetListenerPod.Annotations[AnnotationKeyIntegrityHash] = scaleSetListenerPodIntegrity( newRunnerScaleSetListenerPod, autoscalingListener, podConfig, @@ -468,11 +468,11 @@ func scaleSetListenerPodIntegrity( d := data{ ListenerPodSpec: &pod.Spec, - AutoscalingListenerIntegrityHash: autoscalingListener.Annotations[annotationKeyIntegrityHash], - ConfigSecretIntegrityHash: podConfig.Annotations[annotationKeyIntegrityHash], - ServiceAccountIntegrityHash: serviceAccount.Annotations[annotationKeyIntegrityHash], - RoleIntegrityHash: role.Annotations[annotationKeyIntegrityHash], - RoleBindingIntegrityHash: roleBinding.Annotations[annotationKeyIntegrityHash], + AutoscalingListenerIntegrityHash: autoscalingListener.Annotations[AnnotationKeyIntegrityHash], + ConfigSecretIntegrityHash: podConfig.Annotations[AnnotationKeyIntegrityHash], + ServiceAccountIntegrityHash: serviceAccount.Annotations[AnnotationKeyIntegrityHash], + RoleIntegrityHash: role.Annotations[AnnotationKeyIntegrityHash], + RoleBindingIntegrityHash: roleBinding.Annotations[AnnotationKeyIntegrityHash], MetricsConfig: metricsConfig, } @@ -611,10 +611,10 @@ func (b *ResourceBuilder) newScaleSetListenerServiceAccount(autoscalingListener if autoscalingListener.Spec.ServiceAccountMetadata != nil { base.Labels = b.filterAndMergeLabels(autoscalingListener.Spec.ServiceAccountMetadata.Labels, base.Labels) - base.Annotations = b.mergeAnnotations(autoscalingListener.Spec.ServiceAccountMetadata.Annotations, base.Annotations) + base.Annotations = b.filterAndMergeAnnotations(autoscalingListener.Spec.ServiceAccountMetadata.Annotations, base.Annotations) } - base.Annotations[annotationKeyIntegrityHash] = scaleSetListenerServiceAccountIntegrityHash(base) + base.Annotations[AnnotationKeyIntegrityHash] = scaleSetListenerServiceAccountIntegrityHash(base) if err := b.setControllerReference(autoscalingListener, base); err != nil { return nil, fmt.Errorf("failed to set controller reference for listener service account: %w", err) @@ -650,7 +650,7 @@ func (b *ResourceBuilder) newScaleSetListenerRole(autoscalingListener *v1alpha1. annotations := make(map[string]string) if autoscalingListener.Spec.RoleMetadata != nil { labels = b.filterAndMergeLabels(autoscalingListener.Spec.RoleMetadata.Labels, labels) - annotations = b.mergeAnnotations(autoscalingListener.Spec.RoleMetadata.Annotations, nil) + annotations = b.filterAndMergeAnnotations(autoscalingListener.Spec.RoleMetadata.Annotations, nil) } newRole := &rbacv1.Role{ @@ -663,7 +663,7 @@ func (b *ResourceBuilder) newScaleSetListenerRole(autoscalingListener *v1alpha1. Rules: rulesForListenerRole([]string{autoscalingListener.Spec.EphemeralRunnerSetName}), } - newRole.Annotations[annotationKeyIntegrityHash] = scaleSetRoleIntegrityHash(newRole) + newRole.Annotations[AnnotationKeyIntegrityHash] = scaleSetRoleIntegrityHash(newRole) return newRole } @@ -718,7 +718,7 @@ func (b *ResourceBuilder) newScaleSetListenerRoleBinding(autoscalingListener *v1 Subjects: subjects, } - newRoleBinding.Annotations[annotationKeyIntegrityHash] = scaleSetListenerRoleBindingIntegrityHash(newRoleBinding) + newRoleBinding.Annotations[AnnotationKeyIntegrityHash] = scaleSetListenerRoleBindingIntegrityHash(newRoleBinding) return newRoleBinding } @@ -777,7 +777,7 @@ func (b *ResourceBuilder) newEphemeralRunnerSet(autoscalingRunnerSet *v1alpha1.A if autoscalingRunnerSet.Spec.EphemeralRunnerSetMetadata != nil { labels = b.filterAndMergeLabels(autoscalingRunnerSet.Spec.EphemeralRunnerSetMetadata.Labels, labels) - annotations = b.mergeAnnotations(autoscalingRunnerSet.Spec.EphemeralRunnerSetMetadata.Annotations, annotations) + annotations = b.filterAndMergeAnnotations(autoscalingRunnerSet.Spec.EphemeralRunnerSetMetadata.Annotations, annotations) } newEphemeralRunnerSet := &v1alpha1.EphemeralRunnerSet{ @@ -791,7 +791,7 @@ func (b *ResourceBuilder) newEphemeralRunnerSet(autoscalingRunnerSet *v1alpha1.A Spec: spec, } - newEphemeralRunnerSet.Annotations[annotationKeyIntegrityHash] = ephemeralRunnerSetIntegrityHash(newEphemeralRunnerSet) + newEphemeralRunnerSet.Annotations[AnnotationKeyIntegrityHash] = ephemeralRunnerSetIntegrityHash(newEphemeralRunnerSet) if err := b.setControllerReference(autoscalingRunnerSet, newEphemeralRunnerSet); err != nil { return nil, fmt.Errorf("failed to set controller reference for ephemeral runner set: %w", err) @@ -825,7 +825,7 @@ func (b *ResourceBuilder) newAutoscalingListenerProxySecret(autoscalingListener Data: data, } - newProxySecret.Annotations[annotationKeyIntegrityHash] = autoscalingListenerProxySecretIntegrityHash(newProxySecret) + newProxySecret.Annotations[AnnotationKeyIntegrityHash] = autoscalingListenerProxySecretIntegrityHash(newProxySecret) if err := b.setControllerReference(autoscalingListener, newProxySecret); err != nil { return nil, fmt.Errorf("failed to set controller reference for listener proxy secret: %w", err) @@ -857,7 +857,7 @@ func (b *ResourceBuilder) newEphemeralRunner(ephemeralRunnerSet *v1alpha1.Epheme if ephemeralRunnerSet.Spec.EphemeralRunnerMetadata != nil { labels = b.filterAndMergeLabels(ephemeralRunnerSet.Spec.EphemeralRunnerMetadata.Labels, labels) - annotations = b.mergeAnnotations(ephemeralRunnerSet.Spec.EphemeralRunnerMetadata.Annotations, annotations) + annotations = b.filterAndMergeAnnotations(ephemeralRunnerSet.Spec.EphemeralRunnerMetadata.Annotations, annotations) } ephemeralRunner := &v1alpha1.EphemeralRunner{ @@ -992,7 +992,7 @@ func (b *ResourceBuilder) newEphemeralRunnerSetProxySecret(ephemeralRunnerSet *v Data: data, } - runnerPodProxySecret.Annotations[annotationKeyIntegrityHash] = ephemeralRunnerSetProxySecretZIdentityHash(runnerPodProxySecret) + runnerPodProxySecret.Annotations[AnnotationKeyIntegrityHash] = ephemeralRunnerSetProxySecretZIdentityHash(runnerPodProxySecret) if err := b.setControllerReference(ephemeralRunnerSet, runnerPodProxySecret); err != nil { return nil, fmt.Errorf("failed to set controller reference for ephemeral runner set proxy secret: %w", err) @@ -1093,40 +1093,53 @@ func trimLabelValue(val string) string { return strings.Trim(val, "-_.") } +func (b *ResourceBuilder) filterLabels(k, v string) bool { + for _, prefix := range b.ExcludeLabelPropagationPrefixes { + if strings.HasPrefix(k, prefix) { + return true + } + } + return false +} + func (b *ResourceBuilder) filterAndMergeLabels(base, overwrite map[string]string) map[string]string { + return filterAndMergeMaps(base, overwrite, b.filterLabels) +} + +func filterAndMergeMaps(base, overwrite map[string]string, filter func(k, v string) bool) map[string]string { if base == nil && overwrite == nil { return nil } - - mergedLabels := make(map[string]string, len(base)) -base: - for k, v := range base { - for _, prefix := range b.ExcludeLabelPropagationPrefixes { - if strings.HasPrefix(k, prefix) { - continue base - } - } - mergedLabels[k] = v + var result map[string]string + if len(base) == 0 { + result = make(map[string]string) + } else { + result = maps.Clone(base) } - -overwrite: - for k, v := range overwrite { - for _, prefix := range b.ExcludeLabelPropagationPrefixes { - if strings.HasPrefix(k, prefix) { - continue overwrite - } - } - mergedLabels[k] = v + if len(overwrite) > 0 { + maps.Copy(result, overwrite) } - - return mergedLabels + maps.DeleteFunc(result, filter) + return result } -func (b *ResourceBuilder) mergeAnnotations(base, overwrite map[string]string) map[string]string { +func (b *ResourceBuilder) filterAndMergeAnnotations(base, overwrite map[string]string) map[string]string { if base == nil && overwrite == nil { return nil } - base = maps.Clone(base) - maps.Copy(base, overwrite) - return base + var result map[string]string + if len(base) == 0 { + result = make(map[string]string) + } else { + result = maps.Clone(base) + } + + for k, v := range overwrite { + if k == AnnotationKeyIntegrityHash { + continue + } + result[k] = v + } + + return result } diff --git a/controllers/actions.github.com/resourcebuilder_test.go b/controllers/actions.github.com/resourcebuilder_test.go index d08851173a..206e610748 100644 --- a/controllers/actions.github.com/resourcebuilder_test.go +++ b/controllers/actions.github.com/resourcebuilder_test.go @@ -113,7 +113,7 @@ func TestMetadataPropagation(t *testing.T) { assert.Equal(t, labelValueKubernetesPartOf, ephemeralRunnerSet.Labels[LabelKeyKubernetesPartOf]) assert.Equal(t, "runner-set", ephemeralRunnerSet.Labels[LabelKeyKubernetesComponent]) assert.Equal(t, autoscalingRunnerSet.Labels[LabelKeyKubernetesVersion], ephemeralRunnerSet.Labels[LabelKeyKubernetesVersion]) - assert.NotEmpty(t, ephemeralRunnerSet.Annotations[annotationKeyIntegrityHash]) + assert.NotEmpty(t, ephemeralRunnerSet.Annotations[AnnotationKeyIntegrityHash]) assert.Equal(t, autoscalingRunnerSet.Name, ephemeralRunnerSet.Labels[LabelKeyGitHubScaleSetName]) assert.Equal(t, autoscalingRunnerSet.Namespace, ephemeralRunnerSet.Labels[LabelKeyGitHubScaleSetNamespace]) assert.Equal(t, "", ephemeralRunnerSet.Labels[LabelKeyGitHubEnterprise]) @@ -130,7 +130,7 @@ func TestMetadataPropagation(t *testing.T) { assert.Equal(t, labelValueKubernetesPartOf, listener.Labels[LabelKeyKubernetesPartOf]) assert.Equal(t, "runner-scale-set-listener", listener.Labels[LabelKeyKubernetesComponent]) assert.Equal(t, autoscalingRunnerSet.Labels[LabelKeyKubernetesVersion], listener.Labels[LabelKeyKubernetesVersion]) - assert.NotEmpty(t, ephemeralRunnerSet.Annotations[annotationKeyIntegrityHash]) + assert.NotEmpty(t, ephemeralRunnerSet.Annotations[AnnotationKeyIntegrityHash]) assert.Equal(t, autoscalingRunnerSet.Name, listener.Labels[LabelKeyGitHubScaleSetName]) assert.Equal(t, autoscalingRunnerSet.Namespace, listener.Labels[LabelKeyGitHubScaleSetNamespace]) assert.Equal(t, "", listener.Labels[LabelKeyGitHubEnterprise]) @@ -221,7 +221,7 @@ func TestEphemeralRunnerSetProxySecretZIdentityHash(t *testing.T) { }) require.NoError(t, err) - actualHash := proxySecret.Annotations[annotationKeyIntegrityHash] + actualHash := proxySecret.Annotations[AnnotationKeyIntegrityHash] assert.NotEmpty(t, actualHash) assert.Equal(t, ephemeralRunnerSetProxySecretZIdentityHash(proxySecret), actualHash) @@ -313,7 +313,7 @@ func TestOwnershipRelationships(t *testing.T) { runnerScaleSetIDAnnotationKey: "1", AnnotationKeyGitHubRunnerGroupName: "test-group", AnnotationKeyGitHubRunnerScaleSetName: "test-scale-set", - annotationKeyIntegrityHash: "test-hash", + AnnotationKeyIntegrityHash: "test-hash", }, }, Spec: v1alpha1.AutoscalingRunnerSetSpec{ From 1f5444706a8ed52e0d221c626334c709a271eb0a Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Tue, 16 Jun 2026 00:09:25 +0200 Subject: [PATCH 07/31] fix min/max runners --- controllers/actions.github.com/resourcebuilder.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/controllers/actions.github.com/resourcebuilder.go b/controllers/actions.github.com/resourcebuilder.go index 7c50a092e8..bdce614aaf 100644 --- a/controllers/actions.github.com/resourcebuilder.go +++ b/controllers/actions.github.com/resourcebuilder.go @@ -122,13 +122,14 @@ func (b *ResourceBuilder) newAutoscalingListener(autoscalingRunnerSet *v1alpha1. } effectiveMinRunners := 0 + if autoscalingRunnerSet.Spec.MinRunners != nil { + effectiveMinRunners = *autoscalingRunnerSet.Spec.MinRunners + } + effectiveMaxRunners := math.MaxInt32 if autoscalingRunnerSet.Spec.MaxRunners != nil { effectiveMaxRunners = *autoscalingRunnerSet.Spec.MaxRunners } - if autoscalingRunnerSet.Spec.MinRunners != nil { - effectiveMinRunners = *autoscalingRunnerSet.Spec.MinRunners - } spec := v1alpha1.AutoscalingListenerSpec{ GitHubConfigURL: autoscalingRunnerSet.Spec.GitHubConfigUrl, From 3b15137eca08551e94096095d23d62522b51251f Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Wed, 17 Jun 2026 00:56:20 +0200 Subject: [PATCH 08/31] wip --- .../v1alpha1/autoscalingrunnerset_types.go | 14 -- ...tions.github.com_autoscalinglisteners.yaml | 10 -- ...ions.github.com_autoscalingrunnersets.yaml | 4 - ...tions.github.com_autoscalinglisteners.yaml | 10 -- ...ions.github.com_autoscalingrunnersets.yaml | 4 - ...tions.github.com_autoscalinglisteners.yaml | 10 -- ...ions.github.com_autoscalingrunnersets.yaml | 4 - .../autoscalinglistener_controller.go | 166 ++++++++++-------- .../autoscalingrunnerset_controller.go | 24 +-- .../autoscalingrunnerset_controller_test.go | 138 ++++++++++++--- .../actions.github.com/resourcebuilder.go | 21 +++ controllers/actions.github.com/utils.go | 10 ++ 12 files changed, 247 insertions(+), 168 deletions(-) diff --git a/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go b/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go index 7435e88c45..f268d874a5 100644 --- a/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go +++ b/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go @@ -330,20 +330,6 @@ const ( AutoscalingRunnerSetPhaseOutdated AutoscalingRunnerSetPhase = "Outdated" ) -func (ars *AutoscalingRunnerSet) Hash() string { - type data struct { - Spec *AutoscalingRunnerSetSpec - Labels map[string]string - } - - d := &data{ - Spec: ars.Spec.DeepCopy(), - Labels: ars.Labels, - } - - return hash.ComputeTemplateHash(d) -} - func (ars *AutoscalingRunnerSet) ListenerSpecHash() string { arsSpec := ars.Spec.DeepCopy() spec := arsSpec diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalinglisteners.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalinglisteners.yaml index 9265298660..84e243f9a6 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalinglisteners.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalinglisteners.yaml @@ -8802,16 +8802,6 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object - required: - - autoscalingRunnerSetName - - autoscalingRunnerSetNamespace - - ephemeralRunnerSetName - - githubConfigSecret - - githubConfigUrl - - image - - maxRunners - - minRunners - - runnerScaleSetId type: object status: description: AutoscalingListenerStatus defines the observed state of AutoscalingListener diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml index 083c3d175d..d236b1a99b 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml @@ -16541,10 +16541,6 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object - required: - - githubConfigSecret - - githubConfigUrl - - template type: object status: description: AutoscalingRunnerSetStatus defines the observed state of AutoscalingRunnerSet diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalinglisteners.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalinglisteners.yaml index 9265298660..84e243f9a6 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalinglisteners.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalinglisteners.yaml @@ -8802,16 +8802,6 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object - required: - - autoscalingRunnerSetName - - autoscalingRunnerSetNamespace - - ephemeralRunnerSetName - - githubConfigSecret - - githubConfigUrl - - image - - maxRunners - - minRunners - - runnerScaleSetId type: object status: description: AutoscalingListenerStatus defines the observed state of AutoscalingListener diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml index 083c3d175d..d236b1a99b 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml @@ -16541,10 +16541,6 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object - required: - - githubConfigSecret - - githubConfigUrl - - template type: object status: description: AutoscalingRunnerSetStatus defines the observed state of AutoscalingRunnerSet diff --git a/config/crd/bases/actions.github.com_autoscalinglisteners.yaml b/config/crd/bases/actions.github.com_autoscalinglisteners.yaml index 9265298660..84e243f9a6 100644 --- a/config/crd/bases/actions.github.com_autoscalinglisteners.yaml +++ b/config/crd/bases/actions.github.com_autoscalinglisteners.yaml @@ -8802,16 +8802,6 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object - required: - - autoscalingRunnerSetName - - autoscalingRunnerSetNamespace - - ephemeralRunnerSetName - - githubConfigSecret - - githubConfigUrl - - image - - maxRunners - - minRunners - - runnerScaleSetId type: object status: description: AutoscalingListenerStatus defines the observed state of AutoscalingListener diff --git a/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml b/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml index 083c3d175d..d236b1a99b 100644 --- a/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml +++ b/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml @@ -16541,10 +16541,6 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object - required: - - githubConfigSecret - - githubConfigUrl - - template type: object status: description: AutoscalingRunnerSetStatus defines the observed state of AutoscalingRunnerSet diff --git a/controllers/actions.github.com/autoscalinglistener_controller.go b/controllers/actions.github.com/autoscalinglistener_controller.go index 3b61a3baab..431bdbdd6d 100644 --- a/controllers/actions.github.com/autoscalinglistener_controller.go +++ b/controllers/actions.github.com/autoscalinglistener_controller.go @@ -163,21 +163,22 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, err } + updatedServiceAccount := serviceAccount.DeepCopy() + var shouldUpdate bool desiredLabels := r.filterAndMergeLabels(serviceAccount.Labels, desiredServiceAccount.Labels) - labelsModified := !maps.Equal(serviceAccount.Labels, desiredLabels) + if !maps.Equal(serviceAccount.Labels, desiredLabels) { + updatedServiceAccount.Labels = desiredLabels + shouldUpdate = true + } desiredAnnotations := r.filterAndMergeAnnotations(serviceAccount.Annotations, desiredServiceAccount.Annotations) - annotationsModified := !maps.Equal(serviceAccount.Annotations, desiredAnnotations) - if labelsModified || annotationsModified { - updatedServiceAccount := serviceAccount.DeepCopy() - if labelsModified { - updatedServiceAccount.Labels = desiredLabels - } - if annotationsModified { - updatedServiceAccount.Annotations = desiredAnnotations - } + if !r.annotationsEqual(serviceAccount.Annotations, desiredAnnotations) { + updatedServiceAccount.Annotations = desiredAnnotations + shouldUpdate = true + } + if shouldUpdate { log.Info("Updating listener service account") - if err := r.Patch(ctx, updatedServiceAccount, client.MergeFrom(&serviceAccount)); err != nil { + if err := r.Update(ctx, updatedServiceAccount); err != nil { log.Error(err, "Failed to update listener service account") return ctrl.Result{}, err } @@ -206,24 +207,25 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. switch { case err == nil: desiredRole := r.newScaleSetListenerRole(&autoscalingListener) + updatedRole := listenerRole.DeepCopy() + var shouldUpdate bool desiredLabels := r.filterAndMergeLabels(listenerRole.Labels, desiredRole.Labels) - labelsModified := !maps.Equal(listenerRole.Labels, desiredLabels) + if !maps.Equal(listenerRole.Labels, desiredLabels) { + updatedRole.Labels = desiredLabels + shouldUpdate = true + } desiredAnnotations := r.filterAndMergeAnnotations(listenerRole.Annotations, desiredRole.Annotations) - annotationsModified := !maps.Equal(listenerRole.Annotations, desiredAnnotations) - rulesModified := !reflect.DeepEqual(listenerRole.Rules, desiredRole.Rules) - if labelsModified || annotationsModified || rulesModified { - updatedRole := listenerRole.DeepCopy() - if labelsModified { - updatedRole.Labels = desiredLabels - } - if annotationsModified { - updatedRole.Annotations = desiredAnnotations - } - if rulesModified { - updatedRole.Rules = desiredRole.Rules - } + if !r.annotationsEqual(listenerRole.Annotations, desiredAnnotations) { + updatedRole.Annotations = desiredAnnotations + shouldUpdate = true + } + if !reflect.DeepEqual(listenerRole.Rules, desiredRole.Rules) { + updatedRole.Rules = desiredRole.Rules + shouldUpdate = true + } + if shouldUpdate { log.Info("Updating listener role") - if err := r.Patch(ctx, updatedRole, client.MergeFrom(&listenerRole)); err != nil { + if err := r.Update(ctx, updatedRole); err != nil { log.Error(err, "Failed to update listener role") return ctrl.Result{}, err } @@ -248,20 +250,21 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. &listenerRole, &serviceAccount, ) + updatedRoleBinding := listenerRoleBinding.DeepCopy() + var shouldUpdate bool desiredLabels := r.filterAndMergeLabels(listenerRoleBinding.Labels, desiredRoleBinding.Labels) - labelsModified := !maps.Equal(listenerRoleBinding.Labels, desiredLabels) + if !maps.Equal(listenerRoleBinding.Labels, desiredLabels) { + updatedRoleBinding.Labels = desiredLabels + shouldUpdate = true + } desiredAnnotations := r.filterAndMergeAnnotations(listenerRoleBinding.Annotations, desiredRoleBinding.Annotations) - annotationsModified := !maps.Equal(listenerRoleBinding.Annotations, desiredAnnotations) - if labelsModified || annotationsModified { - updatedRoleBinding := listenerRoleBinding.DeepCopy() - if labelsModified { - updatedRoleBinding.Labels = desiredLabels - } - if annotationsModified { - updatedRoleBinding.Annotations = desiredAnnotations - } + if !r.annotationsEqual(listenerRoleBinding.Annotations, desiredAnnotations) { + updatedRoleBinding.Annotations = desiredAnnotations + shouldUpdate = true + } + if shouldUpdate { log.Info("Updating listener role binding") - if err := r.Patch(ctx, updatedRoleBinding, client.MergeFrom(&listenerRoleBinding)); err != nil { + if err := r.Update(ctx, updatedRoleBinding); err != nil { log.Error(err, "Failed to update listener role binding") return ctrl.Result{}, err } @@ -303,20 +306,21 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. log.Error(err, "Failed to build desired listener proxy secret") return ctrl.Result{}, err } + updatedProxySecret := proxySecret.DeepCopy() + var shouldUpdate bool desiredLabels := r.filterAndMergeLabels(proxySecret.Labels, desiredListenerProxy.Labels) - labelsModified := !maps.Equal(proxySecret.Labels, desiredLabels) + if !maps.Equal(proxySecret.Labels, desiredLabels) { + updatedProxySecret.Labels = desiredLabels + shouldUpdate = true + } desiredAnnotations := r.filterAndMergeAnnotations(proxySecret.Annotations, desiredListenerProxy.Annotations) - annotationsModified := !maps.Equal(proxySecret.Annotations, desiredAnnotations) - if labelsModified || annotationsModified { - updatedProxySecret := proxySecret.DeepCopy() - if labelsModified { - updatedProxySecret.Labels = desiredLabels - } - if annotationsModified { - updatedProxySecret.Annotations = desiredAnnotations - } + if !r.annotationsEqual(proxySecret.Annotations, desiredAnnotations) { + updatedProxySecret.Annotations = desiredAnnotations + shouldUpdate = true + } + if shouldUpdate { log.Info("Updating listener proxy secret") - if err := r.Patch(ctx, updatedProxySecret, client.MergeFrom(&proxySecret)); err != nil { + if err := r.Update(ctx, updatedProxySecret); err != nil { log.Error(err, "Failed to update listener proxy secret") return ctrl.Result{}, err } @@ -389,21 +393,22 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. if err != nil { return ctrl.Result{}, fmt.Errorf("failed to build listener config secret: %w", err) } + updatedSecret := listenerConfigSecret.DeepCopy() + var shouldUpdate bool desiredLabels := r.filterAndMergeLabels(listenerConfigSecret.Labels, desiredSecret.Labels) - labelsModified := !maps.Equal(listenerConfigSecret.Labels, desiredLabels) + if !maps.Equal(listenerConfigSecret.Labels, desiredLabels) { + updatedSecret.Labels = desiredLabels + shouldUpdate = true + } desiredAnnotations := r.filterAndMergeAnnotations(listenerConfigSecret.Annotations, desiredSecret.Annotations) - annotationsModified := !maps.Equal(listenerConfigSecret.Annotations, desiredAnnotations) + if !r.annotationsEqual(listenerConfigSecret.Annotations, desiredAnnotations) { + updatedSecret.Annotations = desiredAnnotations + shouldUpdate = true + } - if labelsModified || annotationsModified { - updatedSecret := listenerConfigSecret.DeepCopy() - if labelsModified { - updatedSecret.Labels = desiredLabels - } - if annotationsModified { - updatedSecret.Annotations = desiredAnnotations - } + if shouldUpdate { log.Info("Updating listener config secret", "namespace", updatedSecret.Namespace, "name", updatedSecret.Name) - if err := r.Patch(ctx, updatedSecret, client.MergeFrom(&listenerConfigSecret)); err != nil { + if err := r.Update(ctx, updatedSecret); err != nil { return ctrl.Result{}, fmt.Errorf("failed to update listener config secret: %w", err) } return ctrl.Result{Requeue: true}, nil @@ -462,9 +467,17 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, err } - shouldReCreate := desiredPod.Annotations[AnnotationKeyIntegrityHash] != listenerPod.Annotations[AnnotationKeyIntegrityHash] - if shouldReCreate { - log.Info("Listener pod dependency changed, recreating listener pod") + if desiredPod.Annotations[AnnotationKeyIntegrityHash] != listenerPod.Annotations[AnnotationKeyIntegrityHash] { + // Since the pod is controlled by a pod controller, we tag the pod with integrity hash. + // If the integrity hash is changed, that means the new spec is different. Keep in mind, the tagged hash + // is created by hashing only the fields this controller sets. + log.Info( + "Listener pod dependency changed, recreating listener pod", + "desiredSpec", + mustJSON(desiredPod.Spec), + "currentSpec", + mustJSON(listenerPod.Spec), + ) if err := r.deleteListenerPod(ctx, &autoscalingListener, &listenerPod, log); err != nil { return ctrl.Result{}, err } @@ -473,21 +486,22 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, nil } + updatedPod := listenerPod.DeepCopy() + var shouldUpdate bool desiredLabels := r.filterAndMergeLabels(listenerPod.Labels, desiredPod.Labels) - labelsModified := !maps.Equal(listenerPod.Labels, desiredLabels) + if !maps.Equal(listenerPod.Labels, desiredLabels) { + updatedPod.Labels = desiredLabels + shouldUpdate = true + } desiredAnnotations := r.filterAndMergeAnnotations(listenerPod.Annotations, desiredPod.Annotations) - annotationsModified := !maps.Equal(listenerPod.Annotations, desiredAnnotations) + if !r.annotationsEqual(listenerPod.Annotations, desiredAnnotations) { + updatedPod.Annotations = desiredAnnotations + shouldUpdate = true + } - if labelsModified || annotationsModified { - updatedPod := listenerPod.DeepCopy() - if labelsModified { - updatedPod.Labels = desiredLabels - } - if annotationsModified { - updatedPod.Annotations = desiredAnnotations - } + if shouldUpdate { log.Info("Updating listener pod", "namespace", updatedPod.Namespace, "name", updatedPod.Name) - if err := r.Patch(ctx, updatedPod, client.MergeFrom(&listenerPod)); err != nil { + if err := r.Update(ctx, updatedPod); err != nil { log.Error(err, "Unable to update listener pod", "namespace", updatedPod.Namespace, "name", updatedPod.Name) return ctrl.Result{}, err } @@ -513,7 +527,11 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, err } - log.Info("Creating listener pod", "namespace", desiredPod.Namespace, "name", desiredPod.Name) + log.Info( + "Creating listener pod", + "namespace", desiredPod.Namespace, + "name", desiredPod.Name, + ) if err := r.Create(ctx, desiredPod); err != nil { log.Error(err, "Unable to create listener pod", "namespace", desiredPod.Namespace, "name", desiredPod.Name) return ctrl.Result{}, err diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller.go b/controllers/actions.github.com/autoscalingrunnerset_controller.go index 1e1e2bb44c..74356ca05f 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller.go @@ -142,13 +142,11 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl } // Something has changed, we need to re-apply the pending phase and change hash annotation to trigger the update of runner scale set and listener. - if targetHash := autoscalingRunnerSet.Hash(); autoscalingRunnerSet.Annotations[AnnotationKeyIntegrityHash] != targetHash { - // TODO: apply the version label + if targetHash := autoscalingRunnerSetIntegrityHash(&autoscalingRunnerSet); autoscalingRunnerSet.Annotations[AnnotationKeyIntegrityHash] != targetHash { original := autoscalingRunnerSet.DeepCopy() if autoscalingRunnerSet.Annotations == nil { autoscalingRunnerSet.Annotations = map[string]string{} } - autoscalingRunnerSet.Annotations[AnnotationKeyIntegrityHash] = targetHash if err := r.Patch(ctx, &autoscalingRunnerSet, client.MergeFrom(original)); err != nil { log.Error(err, "Failed to update autoscaling runner set with new change hash and pending phase") return ctrl.Result{}, err @@ -291,7 +289,8 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl return ctrl.Result{}, nil } - if ephemeralRunnerSet.Annotations[AnnotationKeyIntegrityHash] != desired.Annotations[AnnotationKeyIntegrityHash] { + integrityDiff := desired.Annotations[AnnotationKeyIntegrityHash] != ephemeralRunnerSetIntegrityHash(&ephemeralRunnerSet) + if integrityDiff { original := ephemeralRunnerSet.DeepCopy() ephemeralRunnerSet.Spec.EphemeralRunnerMetadata = desired.Spec.EphemeralRunnerMetadata ephemeralRunnerSet.Spec.EphemeralRunnerSpec = desired.Spec.EphemeralRunnerSpec @@ -310,7 +309,7 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl ephemeralRunnerMetadataModified := !cmp.Equal(ephemeralRunnerSet.Spec.EphemeralRunnerMetadata, desired.Spec.EphemeralRunnerMetadata) ephemeralRunnerLabelsModified := !maps.Equal(ephemeralRunnerSet.Labels, desired.Labels) - ephemeralRunnerAnnotationsModified := !maps.Equal(ephemeralRunnerSet.Annotations, desired.Annotations) + ephemeralRunnerAnnotationsModified := !r.annotationsEqual(ephemeralRunnerSet.Annotations, desired.Annotations) if ephemeralRunnerLabelsModified || ephemeralRunnerAnnotationsModified || ephemeralRunnerMetadataModified { original := ephemeralRunnerSet.DeepCopy() @@ -358,14 +357,17 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl } if !cmp.Equal(listener.Spec, desired.Spec) || - !cmp.Equal(listener.Labels, desired.Labels) || - !cmp.Equal(listener.Annotations, desired.Annotations) { - log.Info("Deleting AutoscalingListener to re-create with updated spec") - if err := r.Delete(ctx, &listener); err != nil { - log.Error(err, "Failed to delete AutoscalingListener for re-creation") + !maps.Equal(listener.Labels, desired.Labels) || + !r.annotationsEqual(listener.Annotations, desired.Annotations) { + log.Info("Updating listener") + listener.Spec = desired.Spec + listener.Annotations = r.filterAndMergeAnnotations(listener.Annotations, desired.Annotations) + listener.Labels = r.filterAndMergeLabels(listener.Labels, desired.Labels) + if err := r.Update(ctx, &listener); err != nil { + log.Error(err, "Failed to update AutoscalingListener with new spec") return ctrl.Result{}, err } - log.Info("Deleted AutoscalingListener, will re-create on next reconcile") + log.Info("Successfully updated AutoscalingListener with new spec") return ctrl.Result{}, nil } } diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go index d67832718d..8b950e47b5 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go @@ -461,7 +461,14 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { listener := new(v1alpha1.AutoscalingListener) Eventually( func() error { - return k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: autoscalingRunnerSet.Namespace}, listener) + return k8sClient.Get( + ctx, + client.ObjectKey{ + Name: scaleSetListenerName(autoscalingRunnerSet), + Namespace: autoscalingRunnerSet.Namespace, + }, + listener, + ) }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, @@ -472,13 +479,21 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { runnerSet := new(v1alpha1.EphemeralRunnerSet) Eventually( func() error { - return k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, runnerSet) + return k8sClient.Get( + ctx, + client.ObjectKey{ + Name: autoscalingRunnerSet.Name, + Namespace: autoscalingRunnerSet.Namespace, + }, + runnerSet, + ) }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, ).Should(Succeed(), "EphemeralRunnerSet should be created") originalRunnerSetUID := runnerSet.UID originalRunnerSetHash := runnerSet.Annotations[AnnotationKeyIntegrityHash] + originalResourceVersion := runnerSet.ResourceVersion patched := autoscalingRunnerSet.DeepCopy() patched.Spec.Template.Spec.Containers[0].Image = "ghcr.io/actions/runner:updated" @@ -492,7 +507,8 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { g.Expect(err).NotTo(HaveOccurred(), "failed to get EphemeralRunnerSet") g.Expect(current.UID).To(Equal(originalRunnerSetUID), "EphemeralRunnerSet should be updated in place") g.Expect(current.Spec.EphemeralRunnerSpec.PodTemplateSpec.Spec.Containers[0].Image).To(Equal("ghcr.io/actions/runner:updated")) - g.Expect(current.Annotations[AnnotationKeyIntegrityHash]).NotTo(Equal(originalRunnerSetHash), "EphemeralRunnerSet spec hash should change") + g.Expect(current.Annotations[AnnotationKeyIntegrityHash]).To(Equal(originalRunnerSetHash), "EphemeralRunnerSet hash integrity key should not be modified") + g.Expect(current.ResourceVersion).NotTo(Equal(originalResourceVersion), "EphemeralRunnerSet ResourceVersion should change after update") }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, @@ -504,34 +520,50 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { err := k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: autoscalingRunnerSet.Namespace}, current) g.Expect(err).NotTo(HaveOccurred(), "failed to get Listener") g.Expect(current.UID).To(Equal(originalListenerUID), "Listener should not be recreated") - g.Expect(current.ResourceVersion).To(Equal(originalListenerResourceVersion), "Listener should not be updated") + g.Expect(current.ResourceVersion).To(Equal(originalListenerResourceVersion), "Listener ResourceVersion should not change after update") }, - time.Second*5, + autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, ).Should(Succeed()) }) - It("recreates only the Listener when max runners changes", func() { + It("Updates only the Listener when max runners changes", func() { listener := new(v1alpha1.AutoscalingListener) Eventually( func() error { - return k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: autoscalingRunnerSet.Namespace}, listener) + return k8sClient.Get( + ctx, + client.ObjectKey{ + Name: scaleSetListenerName(autoscalingRunnerSet), + Namespace: autoscalingRunnerSet.Namespace, + }, + listener, + ) }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, ).Should(Succeed(), "Listener should be created") originalListenerUID := listener.UID + originalListenerResourceVersion := listener.ResourceVersion + originalListenerIntegrityHash := listener.Annotations[AnnotationKeyIntegrityHash] runnerSet := new(v1alpha1.EphemeralRunnerSet) Eventually( func() error { - return k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, runnerSet) + return k8sClient.Get( + ctx, + client.ObjectKey{ + Name: autoscalingRunnerSet.Name, + Namespace: autoscalingRunnerSet.Namespace, + }, + runnerSet, + ) }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, ).Should(Succeed(), "EphemeralRunnerSet should be created") - originalRunnerSetUID := runnerSet.UID - originalRunnerSetHash := runnerSet.Annotations[AnnotationKeyIntegrityHash] + originalERSRunnerSetUID := runnerSet.UID + originalERSResourceVersion := runnerSet.ResourceVersion patched := autoscalingRunnerSet.DeepCopy() max := 20 @@ -544,8 +576,10 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { current := new(v1alpha1.AutoscalingListener) err := k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: autoscalingRunnerSet.Namespace}, current) g.Expect(err).NotTo(HaveOccurred(), "failed to get Listener") - g.Expect(current.UID).NotTo(Equal(originalListenerUID), "Listener should be recreated") + g.Expect(current.UID).To(Equal(originalListenerUID), "Listener should be updated") + g.Expect(current.Annotations[AnnotationKeyIntegrityHash]).To(Equal(originalListenerIntegrityHash), "Listener hash integrity key should not be modified") g.Expect(current.Spec.MaxRunners).To(Equal(max)) + g.Expect(current.ResourceVersion).NotTo(Equal(originalListenerResourceVersion), "Listener ResourceVersion should change after update") }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, @@ -556,8 +590,8 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { current := new(v1alpha1.EphemeralRunnerSet) err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, current) g.Expect(err).NotTo(HaveOccurred(), "failed to get EphemeralRunnerSet") - g.Expect(current.UID).To(Equal(originalRunnerSetUID), "EphemeralRunnerSet should not be recreated") - g.Expect(current.Annotations[AnnotationKeyIntegrityHash]).To(Equal(originalRunnerSetHash), "EphemeralRunnerSet spec should not change") + g.Expect(current.UID).To(Equal(originalERSRunnerSetUID), "EphemeralRunnerSet should not be recreated") + g.Expect(current.ResourceVersion).To(Equal(originalERSResourceVersion), "EphemeralRunnerSet spec should not change") }, time.Second*5, autoscalingRunnerSetTestInterval, @@ -568,7 +602,14 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { runnerSet := new(v1alpha1.EphemeralRunnerSet) Eventually( func() (string, error) { - err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, runnerSet) + err := k8sClient.Get( + ctx, + client.ObjectKey{ + Name: autoscalingRunnerSet.Name, + Namespace: autoscalingRunnerSet.Namespace, + }, + runnerSet, + ) if err != nil { return "", err } @@ -586,7 +627,14 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { Eventually( func() (string, error) { current := new(v1alpha1.EphemeralRunnerSet) - err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, current) + err := k8sClient.Get( + ctx, + client.ObjectKey{ + Name: autoscalingRunnerSet.Name, + Namespace: autoscalingRunnerSet.Namespace, + }, + current, + ) if err != nil { return "", err } @@ -601,7 +649,14 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { runnerSet := new(v1alpha1.EphemeralRunnerSet) Eventually( func() (string, error) { - err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, runnerSet) + err := k8sClient.Get( + ctx, + client.ObjectKey{ + Name: autoscalingRunnerSet.Name, + Namespace: autoscalingRunnerSet.Namespace, + }, + runnerSet, + ) if err != nil { return "", err } @@ -614,6 +669,8 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { patched := autoscalingRunnerSet.DeepCopy() patched.Spec.EphemeralRunnerSetMetadata.Annotations["arc.test/metadata-annotation"] = "updated" patched.Spec.EphemeralRunnerSetMetadata.Annotations["arc.test/new-metadata-annotation"] = "added" + originalERSIntegrityHash := runnerSet.Annotations[AnnotationKeyIntegrityHash] + patched.Spec.EphemeralRunnerSetMetadata.Annotations[AnnotationKeyIntegrityHash] = "must-not-be-modified" err := k8sClient.Patch(ctx, patched, client.MergeFrom(autoscalingRunnerSet)) Expect(err).NotTo(HaveOccurred(), "failed to patch AutoScalingRunnerSet EphemeralRunnerSet metadata") @@ -624,6 +681,7 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { g.Expect(err).NotTo(HaveOccurred(), "failed to get EphemeralRunnerSet") g.Expect(current.Annotations["arc.test/metadata-annotation"]).To(Equal("updated")) g.Expect(current.Annotations["arc.test/new-metadata-annotation"]).To(Equal("added")) + g.Expect(current.Annotations[AnnotationKeyIntegrityHash]).To(Equal(originalERSIntegrityHash), "EphemeralRunnerSet hash integrity key should not be modified") }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, @@ -634,7 +692,14 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { runnerSet := new(v1alpha1.EphemeralRunnerSet) Eventually( func(g Gomega) { - err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, runnerSet) + err := k8sClient.Get( + ctx, + client.ObjectKey{ + Name: autoscalingRunnerSet.Name, + Namespace: autoscalingRunnerSet.Namespace, + }, + runnerSet, + ) g.Expect(err).NotTo(HaveOccurred(), "failed to get EphemeralRunnerSet") g.Expect(runnerSet.Spec.EphemeralRunnerMetadata).NotTo(BeNil()) g.Expect(runnerSet.Spec.EphemeralRunnerMetadata.Labels["arc.test/runner-metadata-label"]).To(Equal("initial")) @@ -719,22 +784,38 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { listener := new(v1alpha1.AutoscalingListener) Eventually( func() error { - return k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: autoscalingRunnerSet.Namespace}, listener) + return k8sClient.Get( + ctx, + client.ObjectKey{ + Name: scaleSetListenerName(autoscalingRunnerSet), + Namespace: autoscalingRunnerSet.Namespace, + }, + listener, + ) }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, ).Should(Succeed(), "Listener should be created") originalListenerUID := listener.UID + originalListenerIntegrityHash := listener.Annotations[AnnotationKeyIntegrityHash] runnerSet := new(v1alpha1.EphemeralRunnerSet) Eventually( func() error { - return k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, runnerSet) + return k8sClient.Get( + ctx, + client.ObjectKey{ + Name: autoscalingRunnerSet.Name, + Namespace: autoscalingRunnerSet.Namespace, + }, + runnerSet, + ) }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, ).Should(Succeed(), "EphemeralRunnerSet should be created") - originalRunnerSetUID := runnerSet.UID + originalEphemeralRunnerSetUID := runnerSet.UID + originalEphemeralRunnerSetIntegrityHash := runnerSet.Annotations[AnnotationKeyIntegrityHash] patched := autoscalingRunnerSet.DeepCopy() patched.Spec.GitHubConfigSecret = updatedSecret.Name @@ -757,8 +838,9 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { current := new(v1alpha1.EphemeralRunnerSet) err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, current) g.Expect(err).NotTo(HaveOccurred(), "failed to get EphemeralRunnerSet") - g.Expect(current.UID).To(Equal(originalRunnerSetUID), "EphemeralRunnerSet should be updated in place") + g.Expect(current.UID).To(Equal(originalEphemeralRunnerSetUID), "EphemeralRunnerSet should be updated in place") g.Expect(current.Spec.EphemeralRunnerSpec.GitHubConfigSecret).To(Equal(updatedSecret.Name)) + g.Expect(current.Annotations[AnnotationKeyIntegrityHash]).To(Equal(originalEphemeralRunnerSetIntegrityHash), "EphemeralRunnerSet hash integrity key should not be modified") }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, @@ -769,8 +851,9 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { current := new(v1alpha1.AutoscalingListener) err := k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: autoscalingRunnerSet.Namespace}, current) g.Expect(err).NotTo(HaveOccurred(), "failed to get Listener") - g.Expect(current.UID).NotTo(Equal(originalListenerUID), "Listener should be recreated") - g.Expect(current.Spec.GitHubConfigSecret).To(Equal(updatedSecret.Name)) + g.Expect(current.UID).To(Equal(originalListenerUID), "Listener should be updated in place") + g.Expect(updatedSecret.Name).To(Equal(current.Spec.GitHubConfigSecret)) + g.Expect(current.Annotations[AnnotationKeyIntegrityHash]).To(Equal(originalListenerIntegrityHash), "Listener hash integrity key should not be modified") }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, @@ -1099,10 +1182,11 @@ var _ = Describe("Test AutoscalingController creation failures", Ordered, func() }, }, Spec: v1alpha1.AutoscalingRunnerSetSpec{ - GitHubConfigUrl: "https://github.com/owner/repo", - MaxRunners: &max, - MinRunners: &min, - RunnerGroup: "testgroup", + GitHubConfigUrl: "https://github.com/owner/repo", + GitHubConfigSecret: "secret1", + MaxRunners: &max, + MinRunners: &min, + RunnerGroup: "testgroup", Template: corev1.PodTemplateSpec{ Spec: corev1.PodSpec{ Containers: []corev1.Container{ diff --git a/controllers/actions.github.com/resourcebuilder.go b/controllers/actions.github.com/resourcebuilder.go index bdce614aaf..e08be51a41 100644 --- a/controllers/actions.github.com/resourcebuilder.go +++ b/controllers/actions.github.com/resourcebuilder.go @@ -115,6 +115,10 @@ func (b *ResourceBuilder) setControllerReference(owner client.Object, object cli return ctrl.SetControllerReference(owner, object, b.Scheme) } +func autoscalingRunnerSetIntegrityHash(ars *v1alpha1.AutoscalingRunnerSet) string { + return hash.ComputeTemplateHash(&ars.Spec) +} + func (b *ResourceBuilder) newAutoscalingListener(autoscalingRunnerSet *v1alpha1.AutoscalingRunnerSet, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, namespace, image string, imagePullSecrets []corev1.LocalObjectReference) (*v1alpha1.AutoscalingListener, error) { runnerScaleSetID, err := strconv.Atoi(autoscalingRunnerSet.Annotations[runnerScaleSetIDAnnotationKey]) if err != nil { @@ -1144,3 +1148,20 @@ func (b *ResourceBuilder) filterAndMergeAnnotations(base, overwrite map[string]s return result } + +// compareAnnotations compares two maps of annotations, ignoring the integrity hash annotation. +func (b *ResourceBuilder) annotationsEqual(m1, m2 map[string]string) bool { + if len(m1) != len(m2) { + return false + } + + for k, v1 := range m1 { + if k == AnnotationKeyIntegrityHash { + continue + } + if v2, ok := m2[k]; !ok || v1 != v2 { + return false + } + } + return true +} diff --git a/controllers/actions.github.com/utils.go b/controllers/actions.github.com/utils.go index a77b24ba17..da87eb7b21 100644 --- a/controllers/actions.github.com/utils.go +++ b/controllers/actions.github.com/utils.go @@ -1,6 +1,8 @@ package actionsgithubcom import ( + "encoding/json" + "k8s.io/apimachinery/pkg/util/rand" ) @@ -25,3 +27,11 @@ func RandStringRunes(n int) string { } return string(b) } + +func mustJSON(v any) string { + val, err := json.Marshal(v) + if err != nil { + panic(err) + } + return string(val) +} From 10ae75d55760c128332bd4434002280ec5d8c287 Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Wed, 17 Jun 2026 14:07:12 +0200 Subject: [PATCH 09/31] wip --- .../actions.github.com/autoscalingrunnerset_controller.go | 1 + 1 file changed, 1 insertion(+) diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller.go b/controllers/actions.github.com/autoscalingrunnerset_controller.go index 74356ca05f..323d0ff613 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller.go @@ -147,6 +147,7 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl if autoscalingRunnerSet.Annotations == nil { autoscalingRunnerSet.Annotations = map[string]string{} } + autoscalingRunnerSet.Annotations[AnnotationKeyIntegrityHash] = targetHash if err := r.Patch(ctx, &autoscalingRunnerSet, client.MergeFrom(original)); err != nil { log.Error(err, "Failed to update autoscaling runner set with new change hash and pending phase") return ctrl.Result{}, err From d2e63d7ca79468906681201448e2bfbc1ebec90d Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Wed, 17 Jun 2026 19:33:56 +0200 Subject: [PATCH 10/31] wip --- config/rbac/role.yaml | 4 +- .../autoscalinglistener_controller.go | 20 +-- .../autoscalinglistener_controller_test.go | 6 +- .../autoscalingrunnerset_controller.go | 3 +- .../autoscalingrunnerset_controller_test.go | 5 +- .../ephemeralrunner_controller_test.go | 121 ++++++++++-------- .../ephemeralrunnerset_controller.go | 7 +- .../ephemeralrunnerset_controller_test.go | 6 +- 8 files changed, 97 insertions(+), 75 deletions(-) diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index dc0becfdf7..007b85160e 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -52,7 +52,7 @@ rules: - delete - get - list - - update + - patch - watch - apiGroups: - actions.github.com @@ -167,5 +167,5 @@ rules: - delete - get - list - - update + - patch - watch diff --git a/controllers/actions.github.com/autoscalinglistener_controller.go b/controllers/actions.github.com/autoscalinglistener_controller.go index 431bdbdd6d..a62462e9fc 100644 --- a/controllers/actions.github.com/autoscalinglistener_controller.go +++ b/controllers/actions.github.com/autoscalinglistener_controller.go @@ -62,10 +62,10 @@ type AutoscalingListenerReconciler struct { // +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=core,resources=pods/status,verbs=get -// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update -// +kubebuilder:rbac:groups=core,resources=serviceaccounts,verbs=get;list;watch;create;update -// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=create;delete;get;list;watch;update -// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=create;delete;get;list;watch;update +// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;patch +// +kubebuilder:rbac:groups=core,resources=serviceaccounts,verbs=get;list;watch;create;patch +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=create;delete;get;list;watch;patch +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=create;delete;get;list;watch;patch // +kubebuilder:rbac:groups=actions.github.com,resources=autoscalinglisteners,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=actions.github.com,resources=autoscalinglisteners/status,verbs=get;update;patch // +kubebuilder:rbac:groups=actions.github.com,resources=autoscalinglisteners/finalizers,verbs=update @@ -178,7 +178,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. if shouldUpdate { log.Info("Updating listener service account") - if err := r.Update(ctx, updatedServiceAccount); err != nil { + if err := r.Patch(ctx, updatedServiceAccount, client.MergeFrom(&serviceAccount)); err != nil { log.Error(err, "Failed to update listener service account") return ctrl.Result{}, err } @@ -225,7 +225,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. } if shouldUpdate { log.Info("Updating listener role") - if err := r.Update(ctx, updatedRole); err != nil { + if err := r.Patch(ctx, updatedRole, client.MergeFrom(&listenerRole)); err != nil { log.Error(err, "Failed to update listener role") return ctrl.Result{}, err } @@ -264,7 +264,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. } if shouldUpdate { log.Info("Updating listener role binding") - if err := r.Update(ctx, updatedRoleBinding); err != nil { + if err := r.Patch(ctx, updatedRoleBinding, client.MergeFrom(&listenerRoleBinding)); err != nil { log.Error(err, "Failed to update listener role binding") return ctrl.Result{}, err } @@ -320,7 +320,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. } if shouldUpdate { log.Info("Updating listener proxy secret") - if err := r.Update(ctx, updatedProxySecret); err != nil { + if err := r.Patch(ctx, updatedProxySecret, client.MergeFrom(&proxySecret)); err != nil { log.Error(err, "Failed to update listener proxy secret") return ctrl.Result{}, err } @@ -408,7 +408,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. if shouldUpdate { log.Info("Updating listener config secret", "namespace", updatedSecret.Namespace, "name", updatedSecret.Name) - if err := r.Update(ctx, updatedSecret); err != nil { + if err := r.Patch(ctx, updatedSecret, client.MergeFrom(&listenerConfigSecret)); err != nil { return ctrl.Result{}, fmt.Errorf("failed to update listener config secret: %w", err) } return ctrl.Result{Requeue: true}, nil @@ -501,7 +501,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. if shouldUpdate { log.Info("Updating listener pod", "namespace", updatedPod.Namespace, "name", updatedPod.Name) - if err := r.Update(ctx, updatedPod); err != nil { + if err := r.Patch(ctx, updatedPod, client.MergeFrom(&listenerPod)); err != nil { log.Error(err, "Unable to update listener pod", "namespace", updatedPod.Namespace, "name", updatedPod.Name) return ctrl.Result{}, err } diff --git a/controllers/actions.github.com/autoscalinglistener_controller_test.go b/controllers/actions.github.com/autoscalinglistener_controller_test.go index 8ec0ebc27c..1d8958066a 100644 --- a/controllers/actions.github.com/autoscalinglistener_controller_test.go +++ b/controllers/actions.github.com/autoscalinglistener_controller_test.go @@ -516,7 +516,7 @@ var _ = Describe("Test AutoScalingListener controller", func() { }, }, } - err := k8sClient.Status().Update(ctx, updated) + err := k8sClient.Status().Patch(ctx, updated, client.MergeFrom(pod)) Expect(err).NotTo(HaveOccurred(), "failed to update test pod") // Waiting for the new pod is created @@ -785,7 +785,7 @@ var _ = Describe("Test AutoScalingListener customization", func() { }, }, } - err := k8sClient.Status().Update(ctx, updated) + err := k8sClient.Status().Patch(ctx, updated, client.MergeFrom(pod)) Expect(err).NotTo(HaveOccurred(), "failed to update pod status") pod = new(corev1.Pod) @@ -831,7 +831,7 @@ var _ = Describe("Test AutoScalingListener customization", func() { updated := pod.DeepCopy() oldPodUID := string(pod.UID) updated.Status.Reason = "Evicted" - err := k8sClient.Status().Update(ctx, updated) + err := k8sClient.Status().Patch(ctx, updated, client.MergeFrom(pod)) Expect(err).NotTo(HaveOccurred(), "failed to update pod status") pod = new(corev1.Pod) diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller.go b/controllers/actions.github.com/autoscalingrunnerset_controller.go index 323d0ff613..14df0d61e6 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller.go @@ -361,10 +361,11 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl !maps.Equal(listener.Labels, desired.Labels) || !r.annotationsEqual(listener.Annotations, desired.Annotations) { log.Info("Updating listener") + original := listener.DeepCopy() listener.Spec = desired.Spec listener.Annotations = r.filterAndMergeAnnotations(listener.Annotations, desired.Annotations) listener.Labels = r.filterAndMergeLabels(listener.Labels, desired.Labels) - if err := r.Update(ctx, &listener); err != nil { + if err := r.Patch(ctx, &listener, client.MergeFrom(original)); err != nil { log.Error(err, "Failed to update AutoscalingListener with new spec") return ctrl.Result{}, err } diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go index 8b950e47b5..400ebcdc57 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go @@ -1220,8 +1220,9 @@ var _ = Describe("Test AutoscalingController creation failures", Ordered, func() autoscalingRunnerSetTestInterval, ).Should(BeEquivalentTo(autoscalingRunnerSetFinalizerName), "AutoScalingRunnerSet should have a finalizer") - ars.Annotations = make(map[string]string) - err = k8sClient.Update(ctx, ars) + updated := ars.DeepCopy() + updated.Annotations = make(map[string]string) + err = k8sClient.Patch(ctx, updated, client.MergeFrom(ars)) Expect(err).NotTo(HaveOccurred(), "Update autoscaling runner set without annotation should be successful") Eventually( diff --git a/controllers/actions.github.com/ephemeralrunner_controller_test.go b/controllers/actions.github.com/ephemeralrunner_controller_test.go index 74aafb71a1..506355acf8 100644 --- a/controllers/actions.github.com/ephemeralrunner_controller_test.go +++ b/controllers/actions.github.com/ephemeralrunner_controller_test.go @@ -223,8 +223,9 @@ var _ = Describe("EphemeralRunner", func() { ).Should(Succeed(), "failed to get ephemeral runner") // update job id to simulate job assigned - er.Status.JobID = "1" - err := k8sClient.Status().Update(ctx, er) + updatedER := er.DeepCopy() + updatedER.Status.JobID = "1" + err := k8sClient.Status().Patch(ctx, updatedER, client.MergeFrom(er)) Expect(err).To(BeNil(), "failed to update ephemeral runner status") er = new(v1alpha1.EphemeralRunner) @@ -249,7 +250,8 @@ var _ = Describe("EphemeralRunner", func() { }).Should(BeEquivalentTo(true)) // delete pod to simulate failure - pod.Status.ContainerStatuses = append(pod.Status.ContainerStatuses, corev1.ContainerStatus{ + updatedPod := pod.DeepCopy() + updatedPod.Status.ContainerStatuses = append(updatedPod.Status.ContainerStatuses, corev1.ContainerStatus{ Name: v1alpha1.EphemeralRunnerContainerName, State: corev1.ContainerState{ Terminated: &corev1.ContainerStateTerminated{ @@ -257,7 +259,7 @@ var _ = Describe("EphemeralRunner", func() { }, }, }) - err = k8sClient.Status().Update(ctx, pod) + err = k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) Expect(err).To(BeNil(), "Failed to update pod status") er = new(v1alpha1.EphemeralRunner) @@ -277,8 +279,9 @@ var _ = Describe("EphemeralRunner", func() { return k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunner.Name, Namespace: ephemeralRunner.Namespace}, er) }, ephemeralRunnerTimeout, ephemeralRunnerInterval).Should(Succeed(), "failed to get ephemeral runner") - er.Status.JobID = "1" - err := k8sClient.Status().Update(ctx, er) + updatedER := er.DeepCopy() + updatedER.Status.JobID = "1" + err := k8sClient.Status().Patch(ctx, updatedER, client.MergeFrom(er)) Expect(err).To(BeNil(), "failed to update ephemeral runner status") Eventually(func() (string, error) { @@ -297,9 +300,10 @@ var _ = Describe("EphemeralRunner", func() { return true, nil }, ephemeralRunnerTimeout, ephemeralRunnerInterval).Should(BeEquivalentTo(true)) - pod.Status.Phase = corev1.PodFailed - pod.Status.ContainerStatuses = nil - err = k8sClient.Status().Update(ctx, pod) + updatedPod := pod.DeepCopy() + updatedPod.Status.Phase = corev1.PodFailed + updatedPod.Status.ContainerStatuses = nil + err = k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) Expect(err).To(BeNil(), "Failed to update pod status") Eventually(func() bool { @@ -320,9 +324,10 @@ var _ = Describe("EphemeralRunner", func() { oldPodUID := pod.UID - pod.Status.Phase = corev1.PodFailed - pod.Status.ContainerStatuses = nil - err := k8sClient.Status().Update(ctx, pod) + updatedPod := pod.DeepCopy() + updatedPod.Status.Phase = corev1.PodFailed + updatedPod.Status.ContainerStatuses = nil + err := k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) Expect(err).To(BeNil(), "Failed to update pod status") Eventually( @@ -369,8 +374,9 @@ var _ = Describe("EphemeralRunner", func() { // Simulate init container failure without PodFailed phase. // This can happen when the kubelet has not yet transitioned the pod phase. - pod.Status.Phase = corev1.PodPending - pod.Status.InitContainerStatuses = []corev1.ContainerStatus{ + updatedPod := pod.DeepCopy() + updatedPod.Status.Phase = corev1.PodPending + updatedPod.Status.InitContainerStatuses = []corev1.ContainerStatus{ { Name: "setup", State: corev1.ContainerState{ @@ -382,7 +388,7 @@ var _ = Describe("EphemeralRunner", func() { }, }, } - err := k8sClient.Status().Update(ctx, pod) + err := k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) Expect(err).To(BeNil(), "Failed to update pod status") Eventually( @@ -422,8 +428,9 @@ var _ = Describe("EphemeralRunner", func() { return k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunner.Name, Namespace: ephemeralRunner.Namespace}, er) }, ephemeralRunnerTimeout, ephemeralRunnerInterval).Should(Succeed(), "failed to get ephemeral runner") - er.Status.JobID = "1" - err := k8sClient.Status().Update(ctx, er) + updatedER := er.DeepCopy() + updatedER.Status.JobID = "1" + err := k8sClient.Status().Patch(ctx, updatedER, client.MergeFrom(er)) Expect(err).To(BeNil(), "failed to update ephemeral runner status") Eventually(func() (string, error) { @@ -443,8 +450,9 @@ var _ = Describe("EphemeralRunner", func() { }, ephemeralRunnerTimeout, ephemeralRunnerInterval).Should(BeEquivalentTo(true)) // Simulate init container failure with job assigned - pod.Status.Phase = corev1.PodPending - pod.Status.InitContainerStatuses = []corev1.ContainerStatus{ + updatedPod := pod.DeepCopy() + updatedPod.Status.Phase = corev1.PodPending + updatedPod.Status.InitContainerStatuses = []corev1.ContainerStatus{ { Name: "setup", State: corev1.ContainerState{ @@ -455,7 +463,7 @@ var _ = Describe("EphemeralRunner", func() { }, }, } - err = k8sClient.Status().Update(ctx, pod) + err = k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) Expect(err).To(BeNil(), "Failed to update pod status") Eventually(func() bool { @@ -471,8 +479,9 @@ var _ = Describe("EphemeralRunner", func() { return k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunner.Name, Namespace: ephemeralRunner.Namespace}, er) }, ephemeralRunnerTimeout, ephemeralRunnerInterval).Should(Succeed(), "failed to get ephemeral runner") - er.Status.JobID = "1" - err := k8sClient.Status().Update(ctx, er) + updatedER := er.DeepCopy() + updatedER.Status.JobID = "1" + err := k8sClient.Status().Patch(ctx, updatedER, client.MergeFrom(er)) Expect(err).To(BeNil(), "failed to update ephemeral runner status") pod := new(corev1.Pod) @@ -487,8 +496,9 @@ var _ = Describe("EphemeralRunner", func() { ephemeralRunnerInterval, ).Should(Succeed(), "failed to get pod") - pod.Status.Phase = corev1.PodFailed - pod.Status.ContainerStatuses = append(pod.Status.ContainerStatuses, corev1.ContainerStatus{ + updatedPod := pod.DeepCopy() + updatedPod.Status.Phase = corev1.PodFailed + updatedPod.Status.ContainerStatuses = append(updatedPod.Status.ContainerStatuses, corev1.ContainerStatus{ Name: v1alpha1.EphemeralRunnerContainerName, State: corev1.ContainerState{ Terminated: &corev1.ContainerStateTerminated{ @@ -496,7 +506,7 @@ var _ = Describe("EphemeralRunner", func() { }, }, }) - err = k8sClient.Status().Update(ctx, pod) + err = k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) Expect(err).To(BeNil(), "Failed to update pod status") Eventually( @@ -523,8 +533,9 @@ var _ = Describe("EphemeralRunner", func() { ephemeralRunnerInterval, ).Should(Succeed(), "failed to get pod") - pod.Status.Phase = corev1.PodFailed - pod.Status.ContainerStatuses = append(pod.Status.ContainerStatuses, corev1.ContainerStatus{ + updatedPod := pod.DeepCopy() + updatedPod.Status.Phase = corev1.PodFailed + updatedPod.Status.ContainerStatuses = append(updatedPod.Status.ContainerStatuses, corev1.ContainerStatus{ Name: v1alpha1.EphemeralRunnerContainerName, State: corev1.ContainerState{ Terminated: &corev1.ContainerStateTerminated{ @@ -532,7 +543,7 @@ var _ = Describe("EphemeralRunner", func() { }, }, }) - err := k8sClient.Status().Update(ctx, pod) + err := k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) Expect(err).To(BeNil(), "Failed to update pod status") Eventually( @@ -568,9 +579,10 @@ var _ = Describe("EphemeralRunner", func() { ephemeralRunnerInterval, ).Should(Succeed(), "failed to get pod") - pod.Status.Phase = corev1.PodFailed + updatedPod := pod.DeepCopy() + updatedPod.Status.Phase = corev1.PodFailed oldPodUID := pod.UID - pod.Status.ContainerStatuses = append(pod.Status.ContainerStatuses, corev1.ContainerStatus{ + updatedPod.Status.ContainerStatuses = append(updatedPod.Status.ContainerStatuses, corev1.ContainerStatus{ Name: v1alpha1.EphemeralRunnerContainerName, State: corev1.ContainerState{ Terminated: &corev1.ContainerStateTerminated{ @@ -579,7 +591,7 @@ var _ = Describe("EphemeralRunner", func() { }, }) - err := k8sClient.Status().Update(ctx, pod) + err := k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) Expect(err).To(BeNil(), "Failed to update pod status") Eventually( @@ -939,8 +951,9 @@ var _ = Describe("EphemeralRunner", func() { ephemeralRunnerInterval, ).Should(BeEquivalentTo(true)) - pod.Status.Phase = corev1.PodRunning - err := k8sClient.Status().Update(ctx, pod) + updatedPod := pod.DeepCopy() + updatedPod.Status.Phase = corev1.PodRunning + err := k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) Expect(err).To(BeNil(), "failed to patch pod status") Consistently( @@ -975,7 +988,8 @@ var _ = Describe("EphemeralRunner", func() { ephemeralRunnerInterval, ).Should(Succeed(), "failed to get ephemeral runner pod") - pod.Status.ContainerStatuses = append(pod.Status.ContainerStatuses, corev1.ContainerStatus{ + updatedPod := pod.DeepCopy() + updatedPod.Status.ContainerStatuses = append(updatedPod.Status.ContainerStatuses, corev1.ContainerStatus{ Name: v1alpha1.EphemeralRunnerContainerName, State: corev1.ContainerState{ Terminated: &corev1.ContainerStateTerminated{ @@ -983,10 +997,10 @@ var _ = Describe("EphemeralRunner", func() { }, }, }) - err := k8sClient.Status().Update(ctx, pod) + err := k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) Expect(err).To(BeNil(), "Failed to update pod status") - return pod + return updatedPod } for i := range 5 { @@ -1065,13 +1079,14 @@ var _ = Describe("EphemeralRunner", func() { ephemeralRunnerInterval, ).Should(BeEquivalentTo(true)) - pod.Status.Phase = corev1.PodFailed - pod.Status.Reason = "Evicted" - pod.Status.ContainerStatuses = append(pod.Status.ContainerStatuses, corev1.ContainerStatus{ + updatedPod := pod.DeepCopy() + updatedPod.Status.Phase = corev1.PodFailed + updatedPod.Status.Reason = "Evicted" + updatedPod.Status.ContainerStatuses = append(updatedPod.Status.ContainerStatuses, corev1.ContainerStatus{ Name: v1alpha1.EphemeralRunnerContainerName, State: corev1.ContainerState{}, }) - err := k8sClient.Status().Update(ctx, pod) + err := k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) Expect(err).To(BeNil(), "failed to patch pod status") updated := new(v1alpha1.EphemeralRunner) @@ -1111,13 +1126,14 @@ var _ = Describe("EphemeralRunner", func() { ephemeralRunnerInterval, ).Should(BeEquivalentTo(true)) - pod.Status.Phase = corev1.PodFailed - pod.Status.Reason = "OutOfpods" - pod.Status.ContainerStatuses = append(pod.Status.ContainerStatuses, corev1.ContainerStatus{ + updatedPod := pod.DeepCopy() + updatedPod.Status.Phase = corev1.PodFailed + updatedPod.Status.Reason = "OutOfpods" + updatedPod.Status.ContainerStatuses = append(updatedPod.Status.ContainerStatuses, corev1.ContainerStatus{ Name: v1alpha1.EphemeralRunnerContainerName, State: corev1.ContainerState{}, }) - err := k8sClient.Status().Update(ctx, pod) + err := k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) Expect(err).To(BeNil(), "failed to patch pod status") updated := new(v1alpha1.EphemeralRunner) @@ -1157,7 +1173,8 @@ var _ = Describe("EphemeralRunner", func() { ).Should(BeEquivalentTo(true)) // first set phase to running - pod.Status.ContainerStatuses = append(pod.Status.ContainerStatuses, corev1.ContainerStatus{ + updatedPod := pod.DeepCopy() + updatedPod.Status.ContainerStatuses = append(updatedPod.Status.ContainerStatuses, corev1.ContainerStatus{ Name: v1alpha1.EphemeralRunnerContainerName, State: corev1.ContainerState{ Running: &corev1.ContainerStateRunning{ @@ -1165,8 +1182,8 @@ var _ = Describe("EphemeralRunner", func() { }, }, }) - pod.Status.Phase = corev1.PodRunning - err := k8sClient.Status().Update(ctx, pod) + updatedPod.Status.Phase = corev1.PodRunning + err := k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) Expect(err).To(BeNil()) Eventually( @@ -1182,8 +1199,9 @@ var _ = Describe("EphemeralRunner", func() { ).Should(BeEquivalentTo(v1alpha1.EphemeralRunnerPhaseRunning)) // set phase to succeeded - pod.Status.Phase = corev1.PodSucceeded - err = k8sClient.Status().Update(ctx, pod) + nextPod := updatedPod.DeepCopy() + nextPod.Status.Phase = corev1.PodSucceeded + err = k8sClient.Status().Patch(ctx, nextPod, client.MergeFrom(updatedPod)) Expect(err).To(BeNil()) Consistently( @@ -1258,7 +1276,8 @@ var _ = Describe("EphemeralRunner", func() { return true, nil }, ephemeralRunnerTimeout, ephemeralRunnerInterval).Should(BeEquivalentTo(true)) - pod.Status.ContainerStatuses = append(pod.Status.ContainerStatuses, corev1.ContainerStatus{ + updatedPod := pod.DeepCopy() + updatedPod.Status.ContainerStatuses = append(updatedPod.Status.ContainerStatuses, corev1.ContainerStatus{ Name: v1alpha1.EphemeralRunnerContainerName, State: corev1.ContainerState{ Terminated: &corev1.ContainerStateTerminated{ @@ -1266,7 +1285,7 @@ var _ = Describe("EphemeralRunner", func() { }, }, }) - err = k8sClient.Status().Update(ctx, pod) + err = k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) Expect(err).To(BeNil(), "failed to update pod status") updated := new(v1alpha1.EphemeralRunner) diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller.go b/controllers/actions.github.com/ephemeralrunnerset_controller.go index a6cb4d27ba..ae5c6359b4 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller.go @@ -260,12 +260,13 @@ func (r *EphemeralRunnerSetReconciler) updateStatus(ctx context.Context, ephemer // Update the status if needed. if ephemeralRunnerSet.Status != desiredStatus { - ephemeralRunnerSet.Status = desiredStatus - if err := r.Status().Update(ctx, ephemeralRunnerSet); err != nil { + updated := ephemeralRunnerSet.DeepCopy() + updated.Status = desiredStatus + if err := r.Status().Patch(ctx, updated, client.MergeFrom(ephemeralRunnerSet)); err != nil { log.Error(err, "Failed to update EphemeralRunnerSet status") return err } - log.Info("Updated EphemeralRunnerSet status", "status", ephemeralRunnerSet.Status) + log.Info("Updated EphemeralRunnerSet status", "status", updated.Status) } return nil diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go index 4526a5b0ff..8d80ce0e55 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go @@ -240,7 +240,7 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { // Scaling up the EphemeralRunnerSet updated := created.DeepCopy() updated.Spec.Replicas = 5 - err := k8sClient.Update(ctx, updated) + err := k8sClient.Patch(ctx, updated, client.MergeFrom(created)) Expect(err).NotTo(HaveOccurred(), "failed to update EphemeralRunnerSet") // Check if the number of ephemeral runners are created @@ -302,7 +302,7 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { // Scale up the EphemeralRunnerSet updated := created.DeepCopy() updated.Spec.Replicas = 5 - err = k8sClient.Update(ctx, updated) + err = k8sClient.Patch(ctx, updated, client.MergeFrom(created)) Expect(err).NotTo(HaveOccurred(), "failed to update EphemeralRunnerSet") // Wait for the EphemeralRunnerSet to be scaled up @@ -1190,7 +1190,7 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { // Scale up the EphemeralRunnerSet updated := created.DeepCopy() updated.Spec.Replicas = 3 - err := k8sClient.Update(ctx, updated) + err := k8sClient.Patch(ctx, updated, client.MergeFrom(created)) Expect(err).NotTo(HaveOccurred(), "failed to update EphemeralRunnerSet replica count") runnerList := new(v1alpha1.EphemeralRunnerList) From 7803f191b1da43f90a0f94e10d0f76d69e6e3af5 Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Tue, 23 Jun 2026 15:49:53 +0200 Subject: [PATCH 11/31] fix equal annotations --- controllers/actions.github.com/resourcebuilder.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/controllers/actions.github.com/resourcebuilder.go b/controllers/actions.github.com/resourcebuilder.go index e08be51a41..68ce5cbc69 100644 --- a/controllers/actions.github.com/resourcebuilder.go +++ b/controllers/actions.github.com/resourcebuilder.go @@ -1151,7 +1151,15 @@ func (b *ResourceBuilder) filterAndMergeAnnotations(base, overwrite map[string]s // compareAnnotations compares two maps of annotations, ignoring the integrity hash annotation. func (b *ResourceBuilder) annotationsEqual(m1, m2 map[string]string) bool { - if len(m1) != len(m2) { + l1 := len(m1) + if _, ok := m1[AnnotationKeyIntegrityHash]; !ok { + l1++ + } + l2 := len(m2) + if _, ok := m2[AnnotationKeyIntegrityHash]; !ok { + l2++ + } + if l1 != l2 { return false } From 6d2a1cdccec9112dddf9ce2be0742bc2bc521157 Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Mon, 6 Jul 2026 14:37:20 +0200 Subject: [PATCH 12/31] Use metrics to display runner statuses instead of status field for EphemeralRunnerSet and AutoscalingRunnerSet --- .../v1alpha1/autoscalingrunnerset_types.go | 51 ++-- .../v1alpha1/ephemeralrunnerset_types.go | 10 +- .../v1alpha1/proxy_config_test.go | 8 +- ...tions.github.com_autoscalinglisteners.yaml | 30 +- ...ions.github.com_autoscalingrunnersets.yaml | 35 +-- .../actions.github.com_ephemeralrunners.yaml | 23 +- ...ctions.github.com_ephemeralrunnersets.yaml | 25 +- ...tions.github.com_autoscalinglisteners.yaml | 30 +- ...ions.github.com_autoscalingrunnersets.yaml | 35 +-- .../actions.github.com_ephemeralrunners.yaml | 23 +- ...ctions.github.com_ephemeralrunnersets.yaml | 25 +- .../templates/_mode_kubernetes.tpl | 5 +- .../templates/manager_role.yaml | 2 - .../templates/manager_role.yaml | 2 - .../tests/template_test.go | 4 +- ...tions.github.com_autoscalinglisteners.yaml | 30 +- ...ions.github.com_autoscalingrunnersets.yaml | 35 +-- .../actions.github.com_ephemeralrunners.yaml | 23 +- ...ctions.github.com_ephemeralrunnersets.yaml | 25 +- config/rbac/role.yaml | 4 +- .../autoscalinglistener_controller.go | 178 +++++------ .../autoscalinglistener_controller_test.go | 10 +- .../autoscalingrunnerset_controller.go | 52 ++-- .../autoscalingrunnerset_controller_test.go | 286 +++++++----------- .../ephemeralrunner_controller_test.go | 127 ++++---- .../ephemeralrunnerset_controller_test.go | 42 +-- .../actions.github.com/resourcebuilder.go | 135 +++------ .../resourcebuilder_test.go | 8 +- .../secretresolver/secret_resolver.go | 8 +- controllers/actions.github.com/utils.go | 10 - 30 files changed, 532 insertions(+), 749 deletions(-) diff --git a/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go b/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go index f268d874a5..11c9edd915 100644 --- a/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go +++ b/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go @@ -36,30 +36,23 @@ import ( // +kubebuilder:subresource:status // +kubebuilder:printcolumn:JSONPath=".spec.minRunners",name=Minimum Runners,type=integer // +kubebuilder:printcolumn:JSONPath=".spec.maxRunners",name=Maximum Runners,type=integer -// +kubebuilder:printcolumn:JSONPath=".status.currentRunners",name=Current Runners,type=integer // +kubebuilder:printcolumn:JSONPath=".status.phase",name=Phase,type=string -// +kubebuilder:printcolumn:JSONPath=".status.pendingEphemeralRunners",name=Pending Runners,type=integer -// +kubebuilder:printcolumn:JSONPath=".status.runningEphemeralRunners",name=Running Runners,type=integer -// +kubebuilder:printcolumn:JSONPath=".status.failedEphemeralRunners",name=Failed Runners,type=integer // AutoscalingRunnerSet is the Schema for the autoscalingrunnersets API type AutoscalingRunnerSet struct { - metav1.TypeMeta `json:",inline"` - // +optional + metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - // +optional - Spec AutoscalingRunnerSetSpec `json:"spec,omitempty"` - // +optional + Spec AutoscalingRunnerSetSpec `json:"spec,omitempty"` Status AutoscalingRunnerSetStatus `json:"status,omitempty"` } // AutoscalingRunnerSetSpec defines the desired state of AutoscalingRunnerSet type AutoscalingRunnerSetSpec struct { - // +optional + // Required GitHubConfigUrl string `json:"githubConfigUrl,omitempty"` - // +optional + // Required GitHubConfigSecret string `json:"githubConfigSecret,omitempty"` // +optional @@ -80,7 +73,7 @@ type AutoscalingRunnerSetSpec struct { // +optional VaultConfig *VaultConfig `json:"vaultConfig,omitempty"` - // +optional + // Required Template corev1.PodTemplateSpec `json:"template,omitempty"` // +optional @@ -114,16 +107,16 @@ type AutoscalingRunnerSetSpec struct { EphemeralRunnerConfigSecretMetadata *ResourceMeta `json:"ephemeralRunnerConfigSecretMetadata,omitempty"` // +optional - // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Minimum:=0 MaxRunners *int `json:"maxRunners,omitempty"` // +optional - // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Minimum:=0 MinRunners *int `json:"minRunners,omitempty"` } type TLSConfig struct { - // +required + // Required CertificateFrom *TLSCertificateSource `json:"certificateFrom,omitempty"` } @@ -160,7 +153,7 @@ func (c *TLSConfig) ToCertPool(keyFetcher func(name, key string) ([]byte, error) } type TLSCertificateSource struct { - // +required + // Required ConfigMapKeyRef *corev1.ConfigMapKeySelector `json:"configMapKeyRef,omitempty"` } @@ -181,9 +174,9 @@ func (c *ProxyConfig) ToHTTPProxyConfig(secretFetcher func(string) (*corev1.Secr } if c.HTTP != nil { - u, err := url.Parse(c.HTTP.URL) + u, err := url.Parse(c.HTTP.Url) if err != nil { - return nil, fmt.Errorf("failed to parse proxy http url %q: %w", c.HTTP.URL, err) + return nil, fmt.Errorf("failed to parse proxy http url %q: %w", c.HTTP.Url, err) } if c.HTTP.CredentialSecretRef != "" { @@ -206,9 +199,9 @@ func (c *ProxyConfig) ToHTTPProxyConfig(secretFetcher func(string) (*corev1.Secr } if c.HTTPS != nil { - u, err := url.Parse(c.HTTPS.URL) + u, err := url.Parse(c.HTTPS.Url) if err != nil { - return nil, fmt.Errorf("failed to parse proxy https url %q: %w", c.HTTPS.URL, err) + return nil, fmt.Errorf("failed to parse proxy https url %q: %w", c.HTTPS.Url, err) } if c.HTTPS.CredentialSecretRef != "" { @@ -261,8 +254,8 @@ func (c *ProxyConfig) ProxyFunc(secretFetcher func(string) (*corev1.Secret, erro } type ProxyServerConfig struct { - // +required - URL string `json:"url,omitempty"` + // Required + Url string `json:"url,omitempty"` // +optional CredentialSecretRef string `json:"credentialSecretRef,omitempty"` @@ -330,6 +323,20 @@ const ( AutoscalingRunnerSetPhaseOutdated AutoscalingRunnerSetPhase = "Outdated" ) +func (ars *AutoscalingRunnerSet) Hash() string { + type data struct { + Spec *AutoscalingRunnerSetSpec + Labels map[string]string + } + + d := &data{ + Spec: ars.Spec.DeepCopy(), + Labels: ars.Labels, + } + + return hash.ComputeTemplateHash(d) +} + func (ars *AutoscalingRunnerSet) ListenerSpecHash() string { arsSpec := ars.Spec.DeepCopy() spec := arsSpec diff --git a/apis/actions.github.com/v1alpha1/ephemeralrunnerset_types.go b/apis/actions.github.com/v1alpha1/ephemeralrunnerset_types.go index 641ec02f88..5e8f12d19f 100644 --- a/apis/actions.github.com/v1alpha1/ephemeralrunnerset_types.go +++ b/apis/actions.github.com/v1alpha1/ephemeralrunnerset_types.go @@ -23,13 +23,10 @@ import ( // EphemeralRunnerSetSpec defines the desired state of EphemeralRunnerSet type EphemeralRunnerSetSpec struct { // Replicas is the number of desired EphemeralRunner resources in the k8s namespace. - // +optional Replicas int `json:"replicas,omitempty"` // PatchID is the unique identifier for the patch issued by the listener app - // +optional PatchID int `json:"patchID"` // EphemeralRunnerSpec is the spec of the ephemeral runner - // +optional EphemeralRunnerSpec EphemeralRunnerSpec `json:"ephemeralRunnerSpec,omitempty"` // EphemeralRunnerMetadata is the metadata to be applied to all ephemeral runners created by this set. // If the EphemeralRunnerMetadata is updated, the update applies to new ephemeral runners created after the update, @@ -61,13 +58,10 @@ const ( // EphemeralRunnerSet is the Schema for the ephemeralrunnersets API type EphemeralRunnerSet struct { - metav1.TypeMeta `json:",inline"` - // +optional + metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - // +optional - Spec EphemeralRunnerSetSpec `json:"spec,omitempty"` - // +optional + Spec EphemeralRunnerSetSpec `json:"spec,omitempty"` Status EphemeralRunnerSetStatus `json:"status,omitempty"` } diff --git a/apis/actions.github.com/v1alpha1/proxy_config_test.go b/apis/actions.github.com/v1alpha1/proxy_config_test.go index 0357e5e268..9291cde4e0 100644 --- a/apis/actions.github.com/v1alpha1/proxy_config_test.go +++ b/apis/actions.github.com/v1alpha1/proxy_config_test.go @@ -14,11 +14,11 @@ import ( func TestProxyConfig_ToSecret(t *testing.T) { config := &v1alpha1.ProxyConfig{ HTTP: &v1alpha1.ProxyServerConfig{ - URL: "http://proxy.example.com:8080", + Url: "http://proxy.example.com:8080", CredentialSecretRef: "my-secret", }, HTTPS: &v1alpha1.ProxyServerConfig{ - URL: "https://proxy.example.com:8080", + Url: "https://proxy.example.com:8080", CredentialSecretRef: "my-secret", }, NoProxy: []string{ @@ -48,11 +48,11 @@ func TestProxyConfig_ToSecret(t *testing.T) { func TestProxyConfig_ProxyFunc(t *testing.T) { config := &v1alpha1.ProxyConfig{ HTTP: &v1alpha1.ProxyServerConfig{ - URL: "http://proxy.example.com:8080", + Url: "http://proxy.example.com:8080", CredentialSecretRef: "my-secret", }, HTTPS: &v1alpha1.ProxyServerConfig{ - URL: "https://proxy.example.com:8080", + Url: "https://proxy.example.com:8080", CredentialSecretRef: "my-secret", }, NoProxy: []string{ diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalinglisteners.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalinglisteners.yaml index 84e243f9a6..20e57e3398 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalinglisteners.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalinglisteners.yaml @@ -51,8 +51,10 @@ spec: description: AutoscalingListenerSpec defines the desired state of AutoscalingListener properties: autoscalingRunnerSetName: + description: Required type: string autoscalingRunnerSetNamespace: + description: Required type: string configSecretMetadata: description: ResourceMeta carries metadata common to all internal @@ -68,17 +70,21 @@ spec: type: object type: object ephemeralRunnerSetName: + description: Required type: string githubConfigSecret: + description: Required type: string githubConfigUrl: + description: Required type: string githubServerTLS: properties: certificateFrom: + description: Required properties: configMapKeyRef: - description: Selects a key from a ConfigMap. + description: Required properties: key: description: The key to select. @@ -100,15 +106,13 @@ spec: - key type: object x-kubernetes-map-type: atomic - required: - - configMapKeyRef type: object - required: - - certificateFrom type: object image: + description: Required type: string imagePullSecrets: + description: Required items: description: |- LocalObjectReference contains enough information to let you locate the @@ -127,6 +131,7 @@ spec: x-kubernetes-map-type: atomic type: array maxRunners: + description: Required minimum: 0 type: integer metrics: @@ -178,6 +183,7 @@ spec: type: object type: object minRunners: + description: Required minimum: 0 type: integer proxy: @@ -187,18 +193,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: @@ -232,7 +236,7 @@ spec: type: object type: object runnerScaleSetId: - minimum: 1 + description: Required type: integer serviceAccountMetadata: description: ResourceMeta carries metadata common to all internal @@ -8778,18 +8782,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml index d236b1a99b..1f4b63f32f 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml @@ -21,21 +21,9 @@ spec: - jsonPath: .spec.maxRunners name: Maximum Runners type: integer - - jsonPath: .status.currentRunners - name: Current Runners - type: integer - jsonPath: .status.phase name: Phase type: string - - jsonPath: .status.pendingEphemeralRunners - name: Pending Runners - type: integer - - jsonPath: .status.runningEphemeralRunners - name: Running Runners - type: integer - - jsonPath: .status.failedEphemeralRunners - name: Failed Runners - type: integer name: v1alpha1 schema: openAPIV3Schema: @@ -110,15 +98,18 @@ spec: type: object type: object githubConfigSecret: + description: Required type: string githubConfigUrl: + description: Required type: string githubServerTLS: properties: certificateFrom: + description: Required properties: configMapKeyRef: - description: Selects a key from a ConfigMap. + description: Required properties: key: description: The key to select. @@ -139,11 +130,7 @@ spec: - key type: object x-kubernetes-map-type: atomic - required: - - configMapKeyRef type: object - required: - - certificateFrom type: object listenerConfigSecretMetadata: description: ResourceMeta carries metadata common to all internal resources @@ -8364,18 +8351,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: @@ -8391,7 +8376,7 @@ spec: runnerScaleSetName: type: string template: - description: PodTemplateSpec describes the data a pod should have when created from a template + description: Required properties: metadata: description: |- @@ -16517,18 +16502,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunners.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunners.yaml index 8248263e7d..3cd90148ca 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunners.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunners.yaml @@ -89,9 +89,10 @@ spec: githubServerTLS: properties: certificateFrom: + description: Required properties: configMapKeyRef: - description: Selects a key from a ConfigMap. + description: Required properties: key: description: The key to select. @@ -112,11 +113,7 @@ spec: - key type: object x-kubernetes-map-type: atomic - required: - - configMapKeyRef type: object - required: - - certificateFrom type: object metadata: description: |- @@ -147,18 +144,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: @@ -8273,18 +8268,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: @@ -8297,6 +8290,10 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object + required: + - githubConfigSecret + - githubConfigUrl + - runnerScaleSetId type: object status: description: EphemeralRunnerStatus defines the observed state of EphemeralRunner diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml index 70b21c9ad6..fa706e3ba6 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml @@ -83,9 +83,10 @@ spec: githubServerTLS: properties: certificateFrom: + description: Required properties: configMapKeyRef: - description: Selects a key from a ConfigMap. + description: Required properties: key: description: The key to select. @@ -106,11 +107,7 @@ spec: - key type: object x-kubernetes-map-type: atomic - required: - - configMapKeyRef type: object - required: - - certificateFrom type: object metadata: description: |- @@ -141,18 +138,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: @@ -8267,18 +8262,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: @@ -8291,6 +8284,10 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object + required: + - githubConfigSecret + - githubConfigUrl + - runnerScaleSetId type: object patchID: description: PatchID is the unique identifier for the patch issued by the listener app @@ -8298,6 +8295,8 @@ spec: replicas: description: Replicas is the number of desired EphemeralRunner resources in the k8s namespace. type: integer + required: + - patchID type: object status: description: EphemeralRunnerSetStatus defines the observed state of EphemeralRunnerSet diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalinglisteners.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalinglisteners.yaml index 84e243f9a6..20e57e3398 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalinglisteners.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalinglisteners.yaml @@ -51,8 +51,10 @@ spec: description: AutoscalingListenerSpec defines the desired state of AutoscalingListener properties: autoscalingRunnerSetName: + description: Required type: string autoscalingRunnerSetNamespace: + description: Required type: string configSecretMetadata: description: ResourceMeta carries metadata common to all internal @@ -68,17 +70,21 @@ spec: type: object type: object ephemeralRunnerSetName: + description: Required type: string githubConfigSecret: + description: Required type: string githubConfigUrl: + description: Required type: string githubServerTLS: properties: certificateFrom: + description: Required properties: configMapKeyRef: - description: Selects a key from a ConfigMap. + description: Required properties: key: description: The key to select. @@ -100,15 +106,13 @@ spec: - key type: object x-kubernetes-map-type: atomic - required: - - configMapKeyRef type: object - required: - - certificateFrom type: object image: + description: Required type: string imagePullSecrets: + description: Required items: description: |- LocalObjectReference contains enough information to let you locate the @@ -127,6 +131,7 @@ spec: x-kubernetes-map-type: atomic type: array maxRunners: + description: Required minimum: 0 type: integer metrics: @@ -178,6 +183,7 @@ spec: type: object type: object minRunners: + description: Required minimum: 0 type: integer proxy: @@ -187,18 +193,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: @@ -232,7 +236,7 @@ spec: type: object type: object runnerScaleSetId: - minimum: 1 + description: Required type: integer serviceAccountMetadata: description: ResourceMeta carries metadata common to all internal @@ -8778,18 +8782,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml index d236b1a99b..1f4b63f32f 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml @@ -21,21 +21,9 @@ spec: - jsonPath: .spec.maxRunners name: Maximum Runners type: integer - - jsonPath: .status.currentRunners - name: Current Runners - type: integer - jsonPath: .status.phase name: Phase type: string - - jsonPath: .status.pendingEphemeralRunners - name: Pending Runners - type: integer - - jsonPath: .status.runningEphemeralRunners - name: Running Runners - type: integer - - jsonPath: .status.failedEphemeralRunners - name: Failed Runners - type: integer name: v1alpha1 schema: openAPIV3Schema: @@ -110,15 +98,18 @@ spec: type: object type: object githubConfigSecret: + description: Required type: string githubConfigUrl: + description: Required type: string githubServerTLS: properties: certificateFrom: + description: Required properties: configMapKeyRef: - description: Selects a key from a ConfigMap. + description: Required properties: key: description: The key to select. @@ -139,11 +130,7 @@ spec: - key type: object x-kubernetes-map-type: atomic - required: - - configMapKeyRef type: object - required: - - certificateFrom type: object listenerConfigSecretMetadata: description: ResourceMeta carries metadata common to all internal resources @@ -8364,18 +8351,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: @@ -8391,7 +8376,7 @@ spec: runnerScaleSetName: type: string template: - description: PodTemplateSpec describes the data a pod should have when created from a template + description: Required properties: metadata: description: |- @@ -16517,18 +16502,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunners.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunners.yaml index 8248263e7d..3cd90148ca 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunners.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunners.yaml @@ -89,9 +89,10 @@ spec: githubServerTLS: properties: certificateFrom: + description: Required properties: configMapKeyRef: - description: Selects a key from a ConfigMap. + description: Required properties: key: description: The key to select. @@ -112,11 +113,7 @@ spec: - key type: object x-kubernetes-map-type: atomic - required: - - configMapKeyRef type: object - required: - - certificateFrom type: object metadata: description: |- @@ -147,18 +144,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: @@ -8273,18 +8268,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: @@ -8297,6 +8290,10 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object + required: + - githubConfigSecret + - githubConfigUrl + - runnerScaleSetId type: object status: description: EphemeralRunnerStatus defines the observed state of EphemeralRunner diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml index 70b21c9ad6..fa706e3ba6 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml @@ -83,9 +83,10 @@ spec: githubServerTLS: properties: certificateFrom: + description: Required properties: configMapKeyRef: - description: Selects a key from a ConfigMap. + description: Required properties: key: description: The key to select. @@ -106,11 +107,7 @@ spec: - key type: object x-kubernetes-map-type: atomic - required: - - configMapKeyRef type: object - required: - - certificateFrom type: object metadata: description: |- @@ -141,18 +138,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: @@ -8267,18 +8262,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: @@ -8291,6 +8284,10 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object + required: + - githubConfigSecret + - githubConfigUrl + - runnerScaleSetId type: object patchID: description: PatchID is the unique identifier for the patch issued by the listener app @@ -8298,6 +8295,8 @@ spec: replicas: description: Replicas is the number of desired EphemeralRunner resources in the k8s namespace. type: integer + required: + - patchID type: object status: description: EphemeralRunnerSetStatus defines the observed state of EphemeralRunnerSet 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 672c8495d4..6589d01d1c 100644 --- a/charts/gha-runner-scale-set-experimental/templates/_mode_kubernetes.tpl +++ b/charts/gha-runner-scale-set-experimental/templates/_mode_kubernetes.tpl @@ -82,10 +82,7 @@ volumeMounts: subPath: extension readOnly: true {{- end }} - {{- with .Values.runner.container.volumeMounts }} - {{- toYaml . | nindent 2 }} - {{- end }} - {{ include "githubServerTLS.volumeMountItem" (dict "root" $ "existingVolumeMounts" (.Values.runner.container.volumeMounts | default (list))) | nindent 2 }} + {{ include "githubServerTLS.volumeMountItem" (dict "root" $ "existingVolumeMounts" (list)) | nindent 2 }} {{- end }} {{- define "runner-mode-kubernetes.pod-volumes" -}} 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 a5c9a258ca..2990ccc49f 100644 --- a/charts/gha-runner-scale-set-experimental/templates/manager_role.yaml +++ b/charts/gha-runner-scale-set-experimental/templates/manager_role.yaml @@ -17,8 +17,6 @@ rules: verbs: - create - delete - - update - - patch - get - apiGroups: - "" diff --git a/charts/gha-runner-scale-set/templates/manager_role.yaml b/charts/gha-runner-scale-set/templates/manager_role.yaml index 88ca5a3f76..bbf9279999 100644 --- a/charts/gha-runner-scale-set/templates/manager_role.yaml +++ b/charts/gha-runner-scale-set/templates/manager_role.yaml @@ -41,8 +41,6 @@ rules: verbs: - create - delete - - update - - patch - get - apiGroups: - "" diff --git a/charts/gha-runner-scale-set/tests/template_test.go b/charts/gha-runner-scale-set/tests/template_test.go index 8c74b956ed..e9aa4c9bcb 100644 --- a/charts/gha-runner-scale-set/tests/template_test.go +++ b/charts/gha-runner-scale-set/tests/template_test.go @@ -1365,11 +1365,11 @@ func TestTemplateRenderedWithProxy(t *testing.T) { require.NotNil(t, ars.Spec.Proxy) require.NotNil(t, ars.Spec.Proxy.HTTP) - assert.Equal(t, "http://proxy.example.com", ars.Spec.Proxy.HTTP.URL) + assert.Equal(t, "http://proxy.example.com", ars.Spec.Proxy.HTTP.Url) assert.Equal(t, "http-secret", ars.Spec.Proxy.HTTP.CredentialSecretRef) require.NotNil(t, ars.Spec.Proxy.HTTPS) - assert.Equal(t, "https://proxy.example.com", ars.Spec.Proxy.HTTPS.URL) + assert.Equal(t, "https://proxy.example.com", ars.Spec.Proxy.HTTPS.Url) assert.Equal(t, "https-secret", ars.Spec.Proxy.HTTPS.CredentialSecretRef) require.NotNil(t, ars.Spec.Proxy.NoProxy) diff --git a/config/crd/bases/actions.github.com_autoscalinglisteners.yaml b/config/crd/bases/actions.github.com_autoscalinglisteners.yaml index 84e243f9a6..20e57e3398 100644 --- a/config/crd/bases/actions.github.com_autoscalinglisteners.yaml +++ b/config/crd/bases/actions.github.com_autoscalinglisteners.yaml @@ -51,8 +51,10 @@ spec: description: AutoscalingListenerSpec defines the desired state of AutoscalingListener properties: autoscalingRunnerSetName: + description: Required type: string autoscalingRunnerSetNamespace: + description: Required type: string configSecretMetadata: description: ResourceMeta carries metadata common to all internal @@ -68,17 +70,21 @@ spec: type: object type: object ephemeralRunnerSetName: + description: Required type: string githubConfigSecret: + description: Required type: string githubConfigUrl: + description: Required type: string githubServerTLS: properties: certificateFrom: + description: Required properties: configMapKeyRef: - description: Selects a key from a ConfigMap. + description: Required properties: key: description: The key to select. @@ -100,15 +106,13 @@ spec: - key type: object x-kubernetes-map-type: atomic - required: - - configMapKeyRef type: object - required: - - certificateFrom type: object image: + description: Required type: string imagePullSecrets: + description: Required items: description: |- LocalObjectReference contains enough information to let you locate the @@ -127,6 +131,7 @@ spec: x-kubernetes-map-type: atomic type: array maxRunners: + description: Required minimum: 0 type: integer metrics: @@ -178,6 +183,7 @@ spec: type: object type: object minRunners: + description: Required minimum: 0 type: integer proxy: @@ -187,18 +193,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: @@ -232,7 +236,7 @@ spec: type: object type: object runnerScaleSetId: - minimum: 1 + description: Required type: integer serviceAccountMetadata: description: ResourceMeta carries metadata common to all internal @@ -8778,18 +8782,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: diff --git a/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml b/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml index d236b1a99b..1f4b63f32f 100644 --- a/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml +++ b/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml @@ -21,21 +21,9 @@ spec: - jsonPath: .spec.maxRunners name: Maximum Runners type: integer - - jsonPath: .status.currentRunners - name: Current Runners - type: integer - jsonPath: .status.phase name: Phase type: string - - jsonPath: .status.pendingEphemeralRunners - name: Pending Runners - type: integer - - jsonPath: .status.runningEphemeralRunners - name: Running Runners - type: integer - - jsonPath: .status.failedEphemeralRunners - name: Failed Runners - type: integer name: v1alpha1 schema: openAPIV3Schema: @@ -110,15 +98,18 @@ spec: type: object type: object githubConfigSecret: + description: Required type: string githubConfigUrl: + description: Required type: string githubServerTLS: properties: certificateFrom: + description: Required properties: configMapKeyRef: - description: Selects a key from a ConfigMap. + description: Required properties: key: description: The key to select. @@ -139,11 +130,7 @@ spec: - key type: object x-kubernetes-map-type: atomic - required: - - configMapKeyRef type: object - required: - - certificateFrom type: object listenerConfigSecretMetadata: description: ResourceMeta carries metadata common to all internal resources @@ -8364,18 +8351,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: @@ -8391,7 +8376,7 @@ spec: runnerScaleSetName: type: string template: - description: PodTemplateSpec describes the data a pod should have when created from a template + description: Required properties: metadata: description: |- @@ -16517,18 +16502,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: diff --git a/config/crd/bases/actions.github.com_ephemeralrunners.yaml b/config/crd/bases/actions.github.com_ephemeralrunners.yaml index 8248263e7d..3cd90148ca 100644 --- a/config/crd/bases/actions.github.com_ephemeralrunners.yaml +++ b/config/crd/bases/actions.github.com_ephemeralrunners.yaml @@ -89,9 +89,10 @@ spec: githubServerTLS: properties: certificateFrom: + description: Required properties: configMapKeyRef: - description: Selects a key from a ConfigMap. + description: Required properties: key: description: The key to select. @@ -112,11 +113,7 @@ spec: - key type: object x-kubernetes-map-type: atomic - required: - - configMapKeyRef type: object - required: - - certificateFrom type: object metadata: description: |- @@ -147,18 +144,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: @@ -8273,18 +8268,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: @@ -8297,6 +8290,10 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object + required: + - githubConfigSecret + - githubConfigUrl + - runnerScaleSetId type: object status: description: EphemeralRunnerStatus defines the observed state of EphemeralRunner diff --git a/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml b/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml index 70b21c9ad6..fa706e3ba6 100644 --- a/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml +++ b/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml @@ -83,9 +83,10 @@ spec: githubServerTLS: properties: certificateFrom: + description: Required properties: configMapKeyRef: - description: Selects a key from a ConfigMap. + description: Required properties: key: description: The key to select. @@ -106,11 +107,7 @@ spec: - key type: object x-kubernetes-map-type: atomic - required: - - configMapKeyRef type: object - required: - - certificateFrom type: object metadata: description: |- @@ -141,18 +138,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: @@ -8267,18 +8262,16 @@ spec: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object https: properties: credentialSecretRef: type: string url: + description: Required type: string - required: - - url type: object noProxy: items: @@ -8291,6 +8284,10 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object + required: + - githubConfigSecret + - githubConfigUrl + - runnerScaleSetId type: object patchID: description: PatchID is the unique identifier for the patch issued by the listener app @@ -8298,6 +8295,8 @@ spec: replicas: description: Replicas is the number of desired EphemeralRunner resources in the k8s namespace. type: integer + required: + - patchID type: object status: description: EphemeralRunnerSetStatus defines the observed state of EphemeralRunnerSet diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 007b85160e..dc0becfdf7 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -52,7 +52,7 @@ rules: - delete - get - list - - patch + - update - watch - apiGroups: - actions.github.com @@ -167,5 +167,5 @@ rules: - delete - get - list - - patch + - update - watch diff --git a/controllers/actions.github.com/autoscalinglistener_controller.go b/controllers/actions.github.com/autoscalinglistener_controller.go index a62462e9fc..c0aa81114c 100644 --- a/controllers/actions.github.com/autoscalinglistener_controller.go +++ b/controllers/actions.github.com/autoscalinglistener_controller.go @@ -62,10 +62,10 @@ type AutoscalingListenerReconciler struct { // +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=core,resources=pods/status,verbs=get -// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;patch -// +kubebuilder:rbac:groups=core,resources=serviceaccounts,verbs=get;list;watch;create;patch -// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=create;delete;get;list;watch;patch -// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=create;delete;get;list;watch;patch +// +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch;create;update +// +kubebuilder:rbac:groups=core,resources=serviceaccounts,verbs=get;list;watch;create;update +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=create;delete;get;list;watch;update +// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=create;delete;get;list;watch;update // +kubebuilder:rbac:groups=actions.github.com,resources=autoscalinglisteners,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=actions.github.com,resources=autoscalinglisteners/status,verbs=get;update;patch // +kubebuilder:rbac:groups=actions.github.com,resources=autoscalinglisteners/finalizers,verbs=update @@ -163,19 +163,18 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, err } - updatedServiceAccount := serviceAccount.DeepCopy() - var shouldUpdate bool desiredLabels := r.filterAndMergeLabels(serviceAccount.Labels, desiredServiceAccount.Labels) - if !maps.Equal(serviceAccount.Labels, desiredLabels) { - updatedServiceAccount.Labels = desiredLabels - shouldUpdate = true - } - desiredAnnotations := r.filterAndMergeAnnotations(serviceAccount.Annotations, desiredServiceAccount.Annotations) - if !r.annotationsEqual(serviceAccount.Annotations, desiredAnnotations) { - updatedServiceAccount.Annotations = desiredAnnotations - shouldUpdate = true - } - if shouldUpdate { + labelsModified := !maps.Equal(serviceAccount.Labels, desiredLabels) + desiredAnnotations := r.mergeAnnotations(serviceAccount.Annotations, desiredServiceAccount.Annotations) + annotationsModified := !maps.Equal(serviceAccount.Annotations, desiredAnnotations) + if labelsModified || annotationsModified { + updatedServiceAccount := serviceAccount.DeepCopy() + if labelsModified { + updatedServiceAccount.Labels = desiredLabels + } + if annotationsModified { + updatedServiceAccount.Annotations = desiredAnnotations + } log.Info("Updating listener service account") if err := r.Patch(ctx, updatedServiceAccount, client.MergeFrom(&serviceAccount)); err != nil { @@ -207,23 +206,22 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. switch { case err == nil: desiredRole := r.newScaleSetListenerRole(&autoscalingListener) - updatedRole := listenerRole.DeepCopy() - var shouldUpdate bool desiredLabels := r.filterAndMergeLabels(listenerRole.Labels, desiredRole.Labels) - if !maps.Equal(listenerRole.Labels, desiredLabels) { - updatedRole.Labels = desiredLabels - shouldUpdate = true - } - desiredAnnotations := r.filterAndMergeAnnotations(listenerRole.Annotations, desiredRole.Annotations) - if !r.annotationsEqual(listenerRole.Annotations, desiredAnnotations) { - updatedRole.Annotations = desiredAnnotations - shouldUpdate = true - } - if !reflect.DeepEqual(listenerRole.Rules, desiredRole.Rules) { - updatedRole.Rules = desiredRole.Rules - shouldUpdate = true - } - if shouldUpdate { + labelsModified := !maps.Equal(listenerRole.Labels, desiredLabels) + desiredAnnotations := r.mergeAnnotations(listenerRole.Annotations, desiredRole.Annotations) + annotationsModified := !maps.Equal(listenerRole.Annotations, desiredAnnotations) + rulesModified := !reflect.DeepEqual(listenerRole.Rules, desiredRole.Rules) + if labelsModified || annotationsModified || rulesModified { + updatedRole := listenerRole.DeepCopy() + if labelsModified { + updatedRole.Labels = desiredLabels + } + if annotationsModified { + updatedRole.Annotations = desiredAnnotations + } + if rulesModified { + updatedRole.Rules = desiredRole.Rules + } log.Info("Updating listener role") if err := r.Patch(ctx, updatedRole, client.MergeFrom(&listenerRole)); err != nil { log.Error(err, "Failed to update listener role") @@ -250,19 +248,18 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. &listenerRole, &serviceAccount, ) - updatedRoleBinding := listenerRoleBinding.DeepCopy() - var shouldUpdate bool desiredLabels := r.filterAndMergeLabels(listenerRoleBinding.Labels, desiredRoleBinding.Labels) - if !maps.Equal(listenerRoleBinding.Labels, desiredLabels) { - updatedRoleBinding.Labels = desiredLabels - shouldUpdate = true - } - desiredAnnotations := r.filterAndMergeAnnotations(listenerRoleBinding.Annotations, desiredRoleBinding.Annotations) - if !r.annotationsEqual(listenerRoleBinding.Annotations, desiredAnnotations) { - updatedRoleBinding.Annotations = desiredAnnotations - shouldUpdate = true - } - if shouldUpdate { + labelsModified := !maps.Equal(listenerRoleBinding.Labels, desiredLabels) + desiredAnnotations := r.mergeAnnotations(listenerRoleBinding.Annotations, desiredRoleBinding.Annotations) + annotationsModified := !maps.Equal(listenerRoleBinding.Annotations, desiredAnnotations) + if labelsModified || annotationsModified { + updatedRoleBinding := listenerRoleBinding.DeepCopy() + if labelsModified { + updatedRoleBinding.Labels = desiredLabels + } + if annotationsModified { + updatedRoleBinding.Annotations = desiredAnnotations + } log.Info("Updating listener role binding") if err := r.Patch(ctx, updatedRoleBinding, client.MergeFrom(&listenerRoleBinding)); err != nil { log.Error(err, "Failed to update listener role binding") @@ -306,19 +303,18 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. log.Error(err, "Failed to build desired listener proxy secret") return ctrl.Result{}, err } - updatedProxySecret := proxySecret.DeepCopy() - var shouldUpdate bool desiredLabels := r.filterAndMergeLabels(proxySecret.Labels, desiredListenerProxy.Labels) - if !maps.Equal(proxySecret.Labels, desiredLabels) { - updatedProxySecret.Labels = desiredLabels - shouldUpdate = true - } - desiredAnnotations := r.filterAndMergeAnnotations(proxySecret.Annotations, desiredListenerProxy.Annotations) - if !r.annotationsEqual(proxySecret.Annotations, desiredAnnotations) { - updatedProxySecret.Annotations = desiredAnnotations - shouldUpdate = true - } - if shouldUpdate { + labelsModified := !maps.Equal(proxySecret.Labels, desiredLabels) + desiredAnnotations := r.mergeAnnotations(proxySecret.Annotations, desiredListenerProxy.Annotations) + annotationsModified := !maps.Equal(proxySecret.Annotations, desiredAnnotations) + if labelsModified || annotationsModified { + updatedProxySecret := proxySecret.DeepCopy() + if labelsModified { + updatedProxySecret.Labels = desiredLabels + } + if annotationsModified { + updatedProxySecret.Annotations = desiredAnnotations + } log.Info("Updating listener proxy secret") if err := r.Patch(ctx, updatedProxySecret, client.MergeFrom(&proxySecret)); err != nil { log.Error(err, "Failed to update listener proxy secret") @@ -393,20 +389,19 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. if err != nil { return ctrl.Result{}, fmt.Errorf("failed to build listener config secret: %w", err) } - updatedSecret := listenerConfigSecret.DeepCopy() - var shouldUpdate bool desiredLabels := r.filterAndMergeLabels(listenerConfigSecret.Labels, desiredSecret.Labels) - if !maps.Equal(listenerConfigSecret.Labels, desiredLabels) { - updatedSecret.Labels = desiredLabels - shouldUpdate = true - } - desiredAnnotations := r.filterAndMergeAnnotations(listenerConfigSecret.Annotations, desiredSecret.Annotations) - if !r.annotationsEqual(listenerConfigSecret.Annotations, desiredAnnotations) { - updatedSecret.Annotations = desiredAnnotations - shouldUpdate = true - } - - if shouldUpdate { + labelsModified := !maps.Equal(listenerConfigSecret.Labels, desiredLabels) + desiredAnnotations := r.mergeAnnotations(listenerConfigSecret.Annotations, desiredSecret.Annotations) + annotationsModified := !maps.Equal(listenerConfigSecret.Annotations, desiredAnnotations) + + if labelsModified || annotationsModified { + updatedSecret := listenerConfigSecret.DeepCopy() + if labelsModified { + updatedSecret.Labels = desiredLabels + } + if annotationsModified { + updatedSecret.Annotations = desiredAnnotations + } log.Info("Updating listener config secret", "namespace", updatedSecret.Namespace, "name", updatedSecret.Name) if err := r.Patch(ctx, updatedSecret, client.MergeFrom(&listenerConfigSecret)); err != nil { return ctrl.Result{}, fmt.Errorf("failed to update listener config secret: %w", err) @@ -467,17 +462,9 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, err } - if desiredPod.Annotations[AnnotationKeyIntegrityHash] != listenerPod.Annotations[AnnotationKeyIntegrityHash] { - // Since the pod is controlled by a pod controller, we tag the pod with integrity hash. - // If the integrity hash is changed, that means the new spec is different. Keep in mind, the tagged hash - // is created by hashing only the fields this controller sets. - log.Info( - "Listener pod dependency changed, recreating listener pod", - "desiredSpec", - mustJSON(desiredPod.Spec), - "currentSpec", - mustJSON(listenerPod.Spec), - ) + shouldReCreate := desiredPod.Annotations[annotationKeyIntegrityHash] != listenerPod.Annotations[annotationKeyIntegrityHash] + if shouldReCreate { + log.Info("Listener pod dependency changed, recreating listener pod") if err := r.deleteListenerPod(ctx, &autoscalingListener, &listenerPod, log); err != nil { return ctrl.Result{}, err } @@ -486,20 +473,19 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, nil } - updatedPod := listenerPod.DeepCopy() - var shouldUpdate bool desiredLabels := r.filterAndMergeLabels(listenerPod.Labels, desiredPod.Labels) - if !maps.Equal(listenerPod.Labels, desiredLabels) { - updatedPod.Labels = desiredLabels - shouldUpdate = true - } - desiredAnnotations := r.filterAndMergeAnnotations(listenerPod.Annotations, desiredPod.Annotations) - if !r.annotationsEqual(listenerPod.Annotations, desiredAnnotations) { - updatedPod.Annotations = desiredAnnotations - shouldUpdate = true - } - - if shouldUpdate { + labelsModified := !maps.Equal(listenerPod.Labels, desiredLabels) + desiredAnnotations := r.mergeAnnotations(listenerPod.Annotations, desiredPod.Annotations) + annotationsModified := !maps.Equal(listenerPod.Annotations, desiredAnnotations) + + if labelsModified || annotationsModified { + updatedPod := listenerPod.DeepCopy() + if labelsModified { + updatedPod.Labels = desiredLabels + } + if annotationsModified { + updatedPod.Annotations = desiredAnnotations + } log.Info("Updating listener pod", "namespace", updatedPod.Namespace, "name", updatedPod.Name) if err := r.Patch(ctx, updatedPod, client.MergeFrom(&listenerPod)); err != nil { log.Error(err, "Unable to update listener pod", "namespace", updatedPod.Namespace, "name", updatedPod.Name) @@ -527,11 +513,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, err } - log.Info( - "Creating listener pod", - "namespace", desiredPod.Namespace, - "name", desiredPod.Name, - ) + log.Info("Creating listener pod", "namespace", desiredPod.Namespace, "name", desiredPod.Name) if err := r.Create(ctx, desiredPod); err != nil { log.Error(err, "Unable to create listener pod", "namespace", desiredPod.Namespace, "name", desiredPod.Name) return ctrl.Result{}, err diff --git a/controllers/actions.github.com/autoscalinglistener_controller_test.go b/controllers/actions.github.com/autoscalinglistener_controller_test.go index 1d8958066a..d48d613e6c 100644 --- a/controllers/actions.github.com/autoscalinglistener_controller_test.go +++ b/controllers/actions.github.com/autoscalinglistener_controller_test.go @@ -516,7 +516,7 @@ var _ = Describe("Test AutoScalingListener controller", func() { }, }, } - err := k8sClient.Status().Patch(ctx, updated, client.MergeFrom(pod)) + err := k8sClient.Status().Update(ctx, updated) Expect(err).NotTo(HaveOccurred(), "failed to update test pod") // Waiting for the new pod is created @@ -785,7 +785,7 @@ var _ = Describe("Test AutoScalingListener customization", func() { }, }, } - err := k8sClient.Status().Patch(ctx, updated, client.MergeFrom(pod)) + err := k8sClient.Status().Update(ctx, updated) Expect(err).NotTo(HaveOccurred(), "failed to update pod status") pod = new(corev1.Pod) @@ -831,7 +831,7 @@ var _ = Describe("Test AutoScalingListener customization", func() { updated := pod.DeepCopy() oldPodUID := string(pod.UID) updated.Status.Reason = "Evicted" - err := k8sClient.Status().Patch(ctx, updated, client.MergeFrom(pod)) + err := k8sClient.Status().Update(ctx, updated) Expect(err).NotTo(HaveOccurred(), "failed to update pod status") pod = new(corev1.Pod) @@ -954,11 +954,11 @@ var _ = Describe("Test AutoScalingListener controller with proxy", func() { proxy := &v1alpha1.ProxyConfig{ HTTP: &v1alpha1.ProxyServerConfig{ - URL: "http://localhost:8080", + Url: "http://localhost:8080", CredentialSecretRef: "proxy-credentials", }, HTTPS: &v1alpha1.ProxyServerConfig{ - URL: "https://localhost:8443", + Url: "https://localhost:8443", CredentialSecretRef: "proxy-credentials", }, NoProxy: []string{ diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller.go b/controllers/actions.github.com/autoscalingrunnerset_controller.go index 14df0d61e6..d1913f27c6 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller.go @@ -142,12 +142,13 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl } // Something has changed, we need to re-apply the pending phase and change hash annotation to trigger the update of runner scale set and listener. - if targetHash := autoscalingRunnerSetIntegrityHash(&autoscalingRunnerSet); autoscalingRunnerSet.Annotations[AnnotationKeyIntegrityHash] != targetHash { + if targetHash := autoscalingRunnerSet.Hash(); autoscalingRunnerSet.Annotations[annotationKeyIntegrityHash] != targetHash { + // TODO: apply the version label original := autoscalingRunnerSet.DeepCopy() if autoscalingRunnerSet.Annotations == nil { autoscalingRunnerSet.Annotations = map[string]string{} } - autoscalingRunnerSet.Annotations[AnnotationKeyIntegrityHash] = targetHash + autoscalingRunnerSet.Annotations[annotationKeyIntegrityHash] = targetHash if err := r.Patch(ctx, &autoscalingRunnerSet, client.MergeFrom(original)); err != nil { log.Error(err, "Failed to update autoscaling runner set with new change hash and pending phase") return ctrl.Result{}, err @@ -290,13 +291,34 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl return ctrl.Result{}, nil } - integrityDiff := desired.Annotations[AnnotationKeyIntegrityHash] != ephemeralRunnerSetIntegrityHash(&ephemeralRunnerSet) - if integrityDiff { + if ephemeralRunnerSet.Annotations[annotationKeyIntegrityHash] != desired.Annotations[annotationKeyIntegrityHash] { + // When runners are actively processing jobs, defer the spec update: + // delete the listener to stop accepting new jobs, but leave the ERS + // (and its running pods) untouched until all jobs have drained. + var ephemeralRunnerList v1alpha1.EphemeralRunnerList + if err := r.List(ctx, &ephemeralRunnerList, + client.InNamespace(ephemeralRunnerSet.Namespace), + client.MatchingFields{resourceOwnerKey: ephemeralRunnerSet.Name}, + ); err != nil { + log.Error(err, "Failed to list ephemeral runners") + return ctrl.Result{}, err + } + + ephemeralRunnersByState := newEphemeralRunnersByStates(&ephemeralRunnerList) + if len(ephemeralRunnersByState.running)+len(ephemeralRunnersByState.pending) > 0 { + log.Info("Ephemeral runner set spec changed but runners are still active; deleting listener to stop new jobs") + if _, err := r.cleanupListener(ctx, &autoscalingRunnerSet, log); err != nil { + log.Error(err, "Failed to clean up listener while waiting for runners to drain") + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: 1 * time.Second}, nil + } + original := ephemeralRunnerSet.DeepCopy() ephemeralRunnerSet.Spec.EphemeralRunnerMetadata = desired.Spec.EphemeralRunnerMetadata ephemeralRunnerSet.Spec.EphemeralRunnerSpec = desired.Spec.EphemeralRunnerSpec ephemeralRunnerSet.Labels = r.filterAndMergeLabels(ephemeralRunnerSet.Labels, desired.Labels) - ephemeralRunnerSet.Annotations = r.filterAndMergeAnnotations(ephemeralRunnerSet.Annotations, desired.Annotations) + ephemeralRunnerSet.Annotations = r.mergeAnnotations(ephemeralRunnerSet.Annotations, desired.Annotations) log.Info("Updating ephemeral runner set spec to match the desired spec") if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original)); err != nil { @@ -310,12 +332,12 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl ephemeralRunnerMetadataModified := !cmp.Equal(ephemeralRunnerSet.Spec.EphemeralRunnerMetadata, desired.Spec.EphemeralRunnerMetadata) ephemeralRunnerLabelsModified := !maps.Equal(ephemeralRunnerSet.Labels, desired.Labels) - ephemeralRunnerAnnotationsModified := !r.annotationsEqual(ephemeralRunnerSet.Annotations, desired.Annotations) + ephemeralRunnerAnnotationsModified := !maps.Equal(ephemeralRunnerSet.Annotations, desired.Annotations) if ephemeralRunnerLabelsModified || ephemeralRunnerAnnotationsModified || ephemeralRunnerMetadataModified { original := ephemeralRunnerSet.DeepCopy() ephemeralRunnerSet.Labels = r.filterAndMergeLabels(ephemeralRunnerSet.Labels, desired.Labels) - ephemeralRunnerSet.Annotations = r.filterAndMergeAnnotations(ephemeralRunnerSet.Annotations, desired.Annotations) + ephemeralRunnerSet.Annotations = r.mergeAnnotations(ephemeralRunnerSet.Annotations, desired.Annotations) ephemeralRunnerSet.Spec.EphemeralRunnerMetadata = desired.Spec.EphemeralRunnerMetadata log.Info("Updating ephemeral runner set metadata to match desired labels and annotations") if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original)); err != nil { @@ -358,18 +380,14 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl } if !cmp.Equal(listener.Spec, desired.Spec) || - !maps.Equal(listener.Labels, desired.Labels) || - !r.annotationsEqual(listener.Annotations, desired.Annotations) { - log.Info("Updating listener") - original := listener.DeepCopy() - listener.Spec = desired.Spec - listener.Annotations = r.filterAndMergeAnnotations(listener.Annotations, desired.Annotations) - listener.Labels = r.filterAndMergeLabels(listener.Labels, desired.Labels) - if err := r.Patch(ctx, &listener, client.MergeFrom(original)); err != nil { - log.Error(err, "Failed to update AutoscalingListener with new spec") + !cmp.Equal(listener.Labels, desired.Labels) || + !cmp.Equal(listener.Annotations, desired.Annotations) { + log.Info("Deleting AutoscalingListener to re-create with updated spec") + if err := r.Delete(ctx, &listener); err != nil { + log.Error(err, "Failed to delete AutoscalingListener for re-creation") return ctrl.Result{}, err } - log.Info("Successfully updated AutoscalingListener with new spec") + log.Info("Deleted AutoscalingListener, will re-create on next reconcile") return ctrl.Result{}, nil } } diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go index 400ebcdc57..e7bb6f0804 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go @@ -461,14 +461,7 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { listener := new(v1alpha1.AutoscalingListener) Eventually( func() error { - return k8sClient.Get( - ctx, - client.ObjectKey{ - Name: scaleSetListenerName(autoscalingRunnerSet), - Namespace: autoscalingRunnerSet.Namespace, - }, - listener, - ) + return k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: autoscalingRunnerSet.Namespace}, listener) }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, @@ -479,21 +472,13 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { runnerSet := new(v1alpha1.EphemeralRunnerSet) Eventually( func() error { - return k8sClient.Get( - ctx, - client.ObjectKey{ - Name: autoscalingRunnerSet.Name, - Namespace: autoscalingRunnerSet.Namespace, - }, - runnerSet, - ) + return k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, runnerSet) }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, ).Should(Succeed(), "EphemeralRunnerSet should be created") originalRunnerSetUID := runnerSet.UID - originalRunnerSetHash := runnerSet.Annotations[AnnotationKeyIntegrityHash] - originalResourceVersion := runnerSet.ResourceVersion + originalRunnerSetHash := runnerSet.Annotations[annotationKeyIntegrityHash] patched := autoscalingRunnerSet.DeepCopy() patched.Spec.Template.Spec.Containers[0].Image = "ghcr.io/actions/runner:updated" @@ -507,8 +492,7 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { g.Expect(err).NotTo(HaveOccurred(), "failed to get EphemeralRunnerSet") g.Expect(current.UID).To(Equal(originalRunnerSetUID), "EphemeralRunnerSet should be updated in place") g.Expect(current.Spec.EphemeralRunnerSpec.PodTemplateSpec.Spec.Containers[0].Image).To(Equal("ghcr.io/actions/runner:updated")) - g.Expect(current.Annotations[AnnotationKeyIntegrityHash]).To(Equal(originalRunnerSetHash), "EphemeralRunnerSet hash integrity key should not be modified") - g.Expect(current.ResourceVersion).NotTo(Equal(originalResourceVersion), "EphemeralRunnerSet ResourceVersion should change after update") + g.Expect(current.Annotations[annotationKeyIntegrityHash]).NotTo(Equal(originalRunnerSetHash), "EphemeralRunnerSet spec hash should change") }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, @@ -520,50 +504,34 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { err := k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: autoscalingRunnerSet.Namespace}, current) g.Expect(err).NotTo(HaveOccurred(), "failed to get Listener") g.Expect(current.UID).To(Equal(originalListenerUID), "Listener should not be recreated") - g.Expect(current.ResourceVersion).To(Equal(originalListenerResourceVersion), "Listener ResourceVersion should not change after update") + g.Expect(current.ResourceVersion).To(Equal(originalListenerResourceVersion), "Listener should not be updated") }, - autoscalingRunnerSetTestTimeout, + time.Second*5, autoscalingRunnerSetTestInterval, ).Should(Succeed()) }) - It("Updates only the Listener when max runners changes", func() { + It("recreates only the Listener when max runners changes", func() { listener := new(v1alpha1.AutoscalingListener) Eventually( func() error { - return k8sClient.Get( - ctx, - client.ObjectKey{ - Name: scaleSetListenerName(autoscalingRunnerSet), - Namespace: autoscalingRunnerSet.Namespace, - }, - listener, - ) + return k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: autoscalingRunnerSet.Namespace}, listener) }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, ).Should(Succeed(), "Listener should be created") originalListenerUID := listener.UID - originalListenerResourceVersion := listener.ResourceVersion - originalListenerIntegrityHash := listener.Annotations[AnnotationKeyIntegrityHash] runnerSet := new(v1alpha1.EphemeralRunnerSet) Eventually( func() error { - return k8sClient.Get( - ctx, - client.ObjectKey{ - Name: autoscalingRunnerSet.Name, - Namespace: autoscalingRunnerSet.Namespace, - }, - runnerSet, - ) + return k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, runnerSet) }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, ).Should(Succeed(), "EphemeralRunnerSet should be created") - originalERSRunnerSetUID := runnerSet.UID - originalERSResourceVersion := runnerSet.ResourceVersion + originalRunnerSetUID := runnerSet.UID + originalRunnerSetHash := runnerSet.Annotations[annotationKeyIntegrityHash] patched := autoscalingRunnerSet.DeepCopy() max := 20 @@ -576,10 +544,8 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { current := new(v1alpha1.AutoscalingListener) err := k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: autoscalingRunnerSet.Namespace}, current) g.Expect(err).NotTo(HaveOccurred(), "failed to get Listener") - g.Expect(current.UID).To(Equal(originalListenerUID), "Listener should be updated") - g.Expect(current.Annotations[AnnotationKeyIntegrityHash]).To(Equal(originalListenerIntegrityHash), "Listener hash integrity key should not be modified") + g.Expect(current.UID).NotTo(Equal(originalListenerUID), "Listener should be recreated") g.Expect(current.Spec.MaxRunners).To(Equal(max)) - g.Expect(current.ResourceVersion).NotTo(Equal(originalListenerResourceVersion), "Listener ResourceVersion should change after update") }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, @@ -590,8 +556,8 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { current := new(v1alpha1.EphemeralRunnerSet) err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, current) g.Expect(err).NotTo(HaveOccurred(), "failed to get EphemeralRunnerSet") - g.Expect(current.UID).To(Equal(originalERSRunnerSetUID), "EphemeralRunnerSet should not be recreated") - g.Expect(current.ResourceVersion).To(Equal(originalERSResourceVersion), "EphemeralRunnerSet spec should not change") + g.Expect(current.UID).To(Equal(originalRunnerSetUID), "EphemeralRunnerSet should not be recreated") + g.Expect(current.Annotations[annotationKeyIntegrityHash]).To(Equal(originalRunnerSetHash), "EphemeralRunnerSet spec should not change") }, time.Second*5, autoscalingRunnerSetTestInterval, @@ -602,14 +568,7 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { runnerSet := new(v1alpha1.EphemeralRunnerSet) Eventually( func() (string, error) { - err := k8sClient.Get( - ctx, - client.ObjectKey{ - Name: autoscalingRunnerSet.Name, - Namespace: autoscalingRunnerSet.Namespace, - }, - runnerSet, - ) + err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, runnerSet) if err != nil { return "", err } @@ -627,14 +586,7 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { Eventually( func() (string, error) { current := new(v1alpha1.EphemeralRunnerSet) - err := k8sClient.Get( - ctx, - client.ObjectKey{ - Name: autoscalingRunnerSet.Name, - Namespace: autoscalingRunnerSet.Namespace, - }, - current, - ) + err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, current) if err != nil { return "", err } @@ -649,14 +601,7 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { runnerSet := new(v1alpha1.EphemeralRunnerSet) Eventually( func() (string, error) { - err := k8sClient.Get( - ctx, - client.ObjectKey{ - Name: autoscalingRunnerSet.Name, - Namespace: autoscalingRunnerSet.Namespace, - }, - runnerSet, - ) + err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, runnerSet) if err != nil { return "", err } @@ -669,8 +614,6 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { patched := autoscalingRunnerSet.DeepCopy() patched.Spec.EphemeralRunnerSetMetadata.Annotations["arc.test/metadata-annotation"] = "updated" patched.Spec.EphemeralRunnerSetMetadata.Annotations["arc.test/new-metadata-annotation"] = "added" - originalERSIntegrityHash := runnerSet.Annotations[AnnotationKeyIntegrityHash] - patched.Spec.EphemeralRunnerSetMetadata.Annotations[AnnotationKeyIntegrityHash] = "must-not-be-modified" err := k8sClient.Patch(ctx, patched, client.MergeFrom(autoscalingRunnerSet)) Expect(err).NotTo(HaveOccurred(), "failed to patch AutoScalingRunnerSet EphemeralRunnerSet metadata") @@ -681,7 +624,6 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { g.Expect(err).NotTo(HaveOccurred(), "failed to get EphemeralRunnerSet") g.Expect(current.Annotations["arc.test/metadata-annotation"]).To(Equal("updated")) g.Expect(current.Annotations["arc.test/new-metadata-annotation"]).To(Equal("added")) - g.Expect(current.Annotations[AnnotationKeyIntegrityHash]).To(Equal(originalERSIntegrityHash), "EphemeralRunnerSet hash integrity key should not be modified") }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, @@ -692,14 +634,7 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { runnerSet := new(v1alpha1.EphemeralRunnerSet) Eventually( func(g Gomega) { - err := k8sClient.Get( - ctx, - client.ObjectKey{ - Name: autoscalingRunnerSet.Name, - Namespace: autoscalingRunnerSet.Namespace, - }, - runnerSet, - ) + err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, runnerSet) g.Expect(err).NotTo(HaveOccurred(), "failed to get EphemeralRunnerSet") g.Expect(runnerSet.Spec.EphemeralRunnerMetadata).NotTo(BeNil()) g.Expect(runnerSet.Spec.EphemeralRunnerMetadata.Labels["arc.test/runner-metadata-label"]).To(Equal("initial")) @@ -784,38 +719,22 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { listener := new(v1alpha1.AutoscalingListener) Eventually( func() error { - return k8sClient.Get( - ctx, - client.ObjectKey{ - Name: scaleSetListenerName(autoscalingRunnerSet), - Namespace: autoscalingRunnerSet.Namespace, - }, - listener, - ) + return k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: autoscalingRunnerSet.Namespace}, listener) }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, ).Should(Succeed(), "Listener should be created") originalListenerUID := listener.UID - originalListenerIntegrityHash := listener.Annotations[AnnotationKeyIntegrityHash] runnerSet := new(v1alpha1.EphemeralRunnerSet) Eventually( func() error { - return k8sClient.Get( - ctx, - client.ObjectKey{ - Name: autoscalingRunnerSet.Name, - Namespace: autoscalingRunnerSet.Namespace, - }, - runnerSet, - ) + return k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, runnerSet) }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, ).Should(Succeed(), "EphemeralRunnerSet should be created") - originalEphemeralRunnerSetUID := runnerSet.UID - originalEphemeralRunnerSetIntegrityHash := runnerSet.Annotations[AnnotationKeyIntegrityHash] + originalRunnerSetUID := runnerSet.UID patched := autoscalingRunnerSet.DeepCopy() patched.Spec.GitHubConfigSecret = updatedSecret.Name @@ -838,9 +757,8 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { current := new(v1alpha1.EphemeralRunnerSet) err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, current) g.Expect(err).NotTo(HaveOccurred(), "failed to get EphemeralRunnerSet") - g.Expect(current.UID).To(Equal(originalEphemeralRunnerSetUID), "EphemeralRunnerSet should be updated in place") + g.Expect(current.UID).To(Equal(originalRunnerSetUID), "EphemeralRunnerSet should be updated in place") g.Expect(current.Spec.EphemeralRunnerSpec.GitHubConfigSecret).To(Equal(updatedSecret.Name)) - g.Expect(current.Annotations[AnnotationKeyIntegrityHash]).To(Equal(originalEphemeralRunnerSetIntegrityHash), "EphemeralRunnerSet hash integrity key should not be modified") }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, @@ -851,9 +769,8 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { current := new(v1alpha1.AutoscalingListener) err := k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: autoscalingRunnerSet.Namespace}, current) g.Expect(err).NotTo(HaveOccurred(), "failed to get Listener") - g.Expect(current.UID).To(Equal(originalListenerUID), "Listener should be updated in place") - g.Expect(updatedSecret.Name).To(Equal(current.Spec.GitHubConfigSecret)) - g.Expect(current.Annotations[AnnotationKeyIntegrityHash]).To(Equal(originalListenerIntegrityHash), "Listener hash integrity key should not be modified") + g.Expect(current.UID).NotTo(Equal(originalListenerUID), "Listener should be recreated") + g.Expect(current.Spec.GitHubConfigSecret).To(Equal(updatedSecret.Name)) }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, @@ -920,64 +837,97 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { }) }) - It("Should update Status on EphemeralRunnerSet status Update", func() { - ars := new(v1alpha1.AutoscalingRunnerSet) - Eventually( - func() (bool, error) { - err := k8sClient.Get( - ctx, - client.ObjectKey{ - Name: autoscalingRunnerSet.Name, - Namespace: autoscalingRunnerSet.Namespace, - }, - ars, - ) - if err != nil { - return false, err - } - return true, nil - }, - autoscalingRunnerSetTestTimeout, - autoscalingRunnerSetTestInterval, - ).Should(BeTrue(), "AutoscalingRunnerSet should be created") + Context("When updating an AutoscalingRunnerSet with running or pending jobs", func() { + It("It should wait for running and pending jobs to finish before applying the update.", func() { + // Wait till the listener is created + listener := new(v1alpha1.AutoscalingListener) + Eventually( + func() error { + return k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: autoscalingRunnerSet.Namespace}, listener) + }, + autoscalingRunnerSetTestTimeout, + autoscalingRunnerSetTestInterval, + ).Should(Succeed(), "Listener should be created") - runnerSetList := new(v1alpha1.EphemeralRunnerSetList) - Eventually( - func() (int, error) { - err := k8sClient.List(ctx, runnerSetList, client.InNamespace(ars.Namespace)) - if err != nil { - return 0, err - } - return len(runnerSetList.Items), nil - }, - autoscalingRunnerSetTestTimeout, - autoscalingRunnerSetTestInterval, - ).Should(BeEquivalentTo(1), "Failed to fetch runner set list") + // Wait till the ephemeral runner set is created + Eventually( + func() (int, error) { + runnerSetList := new(v1alpha1.EphemeralRunnerSetList) + err := k8sClient.List(ctx, runnerSetList, client.InNamespace(autoscalingRunnerSet.Namespace)) + if err != nil { + return 0, err + } - runnerSet := runnerSetList.Items[0] - statusUpdate := runnerSet.DeepCopy() - statusUpdate.Status.Phase = v1alpha1.EphemeralRunnerSetPhaseRunning + return len(runnerSetList.Items), nil + }, + autoscalingRunnerSetTestTimeout, + autoscalingRunnerSetTestInterval, + ).Should(BeEquivalentTo(1), "Only one EphemeralRunnerSet should be created") - desiredStatus := v1alpha1.AutoscalingRunnerSetStatus{ - Phase: v1alpha1.AutoscalingRunnerSetPhaseRunning, - } + runnerSetList := new(v1alpha1.EphemeralRunnerSetList) + err := k8sClient.List(ctx, runnerSetList, client.InNamespace(autoscalingRunnerSet.Namespace)) + Expect(err).NotTo(HaveOccurred(), "failed to list EphemeralRunnerSet") - err := k8sClient.Status().Patch(ctx, statusUpdate, client.MergeFrom(&runnerSet)) - Expect(err).NotTo(HaveOccurred(), "Failed to patch runner set status") + // Emulate running and pending jobs + runnerSet := runnerSetList.Items[0] + activeRunnerSet := runnerSet.DeepCopy() + for _, phase := range []v1alpha1.EphemeralRunnerPhase{ + v1alpha1.EphemeralRunnerPhaseRunning, + v1alpha1.EphemeralRunnerPhasePending, + } { + runner, err := controller.newEphemeralRunner(activeRunnerSet) + Expect(err).NotTo(HaveOccurred(), "Failed to create active runner") + err = k8sClient.Create(ctx, runner) + Expect(err).NotTo(HaveOccurred(), "Failed to create active runner") + + updatedRunner := runner.DeepCopy() + updatedRunner.Status.Phase = phase + err = k8sClient.Status().Patch(ctx, updatedRunner, client.MergeFrom(runner)) + Expect(err).NotTo(HaveOccurred(), "Failed to patch active runner status") + } - Eventually( - func() (v1alpha1.AutoscalingRunnerSetStatus, error) { - updated := new(v1alpha1.AutoscalingRunnerSet) - err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, updated) - if err != nil { - return v1alpha1.AutoscalingRunnerSetStatus{}, fmt.Errorf("failed to get AutoScalingRunnerSet: %w", err) - } - return updated.Status, nil - }, - autoscalingRunnerSetTestTimeout, - autoscalingRunnerSetTestInterval, - ).Should(BeEquivalentTo(desiredStatus), "AutoScalingRunnerSet status should be updated") + // Patch the AutoScalingRunnerSet image which should trigger + // the recreation of the Listener and EphemeralRunnerSet + patched := autoscalingRunnerSet.DeepCopy() + if patched.Annotations == nil { + patched.Annotations = make(map[string]string) + } + patched.Annotations[annotationKeyIntegrityHash] = "testgroup2" + patched.Spec.Template.Spec = corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "runner", + Image: "ghcr.io/actions/abcd:1.1.1", + }, + }, + } + err = k8sClient.Patch(ctx, patched, client.MergeFrom(autoscalingRunnerSet)) + Expect(err).NotTo(HaveOccurred(), "failed to patch AutoScalingRunnerSet") + autoscalingRunnerSet = patched.DeepCopy() + + // The EphemeralRunnerSet should not be recreated + Consistently( + func() (string, error) { + runnerSetList := new(v1alpha1.EphemeralRunnerSetList) + err := k8sClient.List(ctx, runnerSetList, client.InNamespace(autoscalingRunnerSet.Namespace)) + Expect(err).NotTo(HaveOccurred(), "failed to fetch AutoScalingRunnerSet") + return runnerSetList.Items[0].Name, nil + }, + autoscalingRunnerSetTestTimeout, + autoscalingRunnerSetTestInterval, + ).Should(Equal(activeRunnerSet.Name), "The EphemeralRunnerSet should not be recreated") + + // The listener should not be recreated + Consistently( + func() error { + return k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: autoscalingRunnerSet.Namespace}, listener) + }, + autoscalingRunnerSetTestTimeout, + autoscalingRunnerSetTestInterval, + ).ShouldNot(Succeed(), "Listener should not be recreated") + }) }) + }) var _ = Describe("Test AutoScalingController updates", Ordered, func() { @@ -1182,11 +1132,10 @@ var _ = Describe("Test AutoscalingController creation failures", Ordered, func() }, }, Spec: v1alpha1.AutoscalingRunnerSetSpec{ - GitHubConfigUrl: "https://github.com/owner/repo", - GitHubConfigSecret: "secret1", - MaxRunners: &max, - MinRunners: &min, - RunnerGroup: "testgroup", + GitHubConfigUrl: "https://github.com/owner/repo", + MaxRunners: &max, + MinRunners: &min, + RunnerGroup: "testgroup", Template: corev1.PodTemplateSpec{ Spec: corev1.PodSpec{ Containers: []corev1.Container{ @@ -1220,9 +1169,8 @@ var _ = Describe("Test AutoscalingController creation failures", Ordered, func() autoscalingRunnerSetTestInterval, ).Should(BeEquivalentTo(autoscalingRunnerSetFinalizerName), "AutoScalingRunnerSet should have a finalizer") - updated := ars.DeepCopy() - updated.Annotations = make(map[string]string) - err = k8sClient.Patch(ctx, updated, client.MergeFrom(ars)) + ars.Annotations = make(map[string]string) + err = k8sClient.Update(ctx, ars) Expect(err).NotTo(HaveOccurred(), "Update autoscaling runner set without annotation should be successful") Eventually( @@ -1326,7 +1274,7 @@ var _ = Describe("Test client optional configuration", Ordered, func() { RunnerGroup: "testgroup", Proxy: &v1alpha1.ProxyConfig{ HTTP: &v1alpha1.ProxyServerConfig{ - URL: proxy.URL, + Url: proxy.URL, }, }, Template: corev1.PodTemplateSpec{ @@ -1404,7 +1352,7 @@ var _ = Describe("Test client optional configuration", Ordered, func() { RunnerGroup: "testgroup", Proxy: &v1alpha1.ProxyConfig{ HTTP: &v1alpha1.ProxyServerConfig{ - URL: "http://test:password@" + proxy.Listener.Addr().String(), + Url: "http://test:password@" + proxy.Listener.Addr().String(), CredentialSecretRef: "proxy-credentials", }, }, diff --git a/controllers/actions.github.com/ephemeralrunner_controller_test.go b/controllers/actions.github.com/ephemeralrunner_controller_test.go index 506355acf8..80c27134a7 100644 --- a/controllers/actions.github.com/ephemeralrunner_controller_test.go +++ b/controllers/actions.github.com/ephemeralrunner_controller_test.go @@ -223,9 +223,8 @@ var _ = Describe("EphemeralRunner", func() { ).Should(Succeed(), "failed to get ephemeral runner") // update job id to simulate job assigned - updatedER := er.DeepCopy() - updatedER.Status.JobID = "1" - err := k8sClient.Status().Patch(ctx, updatedER, client.MergeFrom(er)) + er.Status.JobID = "1" + err := k8sClient.Status().Update(ctx, er) Expect(err).To(BeNil(), "failed to update ephemeral runner status") er = new(v1alpha1.EphemeralRunner) @@ -250,8 +249,7 @@ var _ = Describe("EphemeralRunner", func() { }).Should(BeEquivalentTo(true)) // delete pod to simulate failure - updatedPod := pod.DeepCopy() - updatedPod.Status.ContainerStatuses = append(updatedPod.Status.ContainerStatuses, corev1.ContainerStatus{ + pod.Status.ContainerStatuses = append(pod.Status.ContainerStatuses, corev1.ContainerStatus{ Name: v1alpha1.EphemeralRunnerContainerName, State: corev1.ContainerState{ Terminated: &corev1.ContainerStateTerminated{ @@ -259,7 +257,7 @@ var _ = Describe("EphemeralRunner", func() { }, }, }) - err = k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) + err = k8sClient.Status().Update(ctx, pod) Expect(err).To(BeNil(), "Failed to update pod status") er = new(v1alpha1.EphemeralRunner) @@ -279,9 +277,8 @@ var _ = Describe("EphemeralRunner", func() { return k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunner.Name, Namespace: ephemeralRunner.Namespace}, er) }, ephemeralRunnerTimeout, ephemeralRunnerInterval).Should(Succeed(), "failed to get ephemeral runner") - updatedER := er.DeepCopy() - updatedER.Status.JobID = "1" - err := k8sClient.Status().Patch(ctx, updatedER, client.MergeFrom(er)) + er.Status.JobID = "1" + err := k8sClient.Status().Update(ctx, er) Expect(err).To(BeNil(), "failed to update ephemeral runner status") Eventually(func() (string, error) { @@ -300,10 +297,9 @@ var _ = Describe("EphemeralRunner", func() { return true, nil }, ephemeralRunnerTimeout, ephemeralRunnerInterval).Should(BeEquivalentTo(true)) - updatedPod := pod.DeepCopy() - updatedPod.Status.Phase = corev1.PodFailed - updatedPod.Status.ContainerStatuses = nil - err = k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) + pod.Status.Phase = corev1.PodFailed + pod.Status.ContainerStatuses = nil + err = k8sClient.Status().Update(ctx, pod) Expect(err).To(BeNil(), "Failed to update pod status") Eventually(func() bool { @@ -324,10 +320,9 @@ var _ = Describe("EphemeralRunner", func() { oldPodUID := pod.UID - updatedPod := pod.DeepCopy() - updatedPod.Status.Phase = corev1.PodFailed - updatedPod.Status.ContainerStatuses = nil - err := k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) + pod.Status.Phase = corev1.PodFailed + pod.Status.ContainerStatuses = nil + err := k8sClient.Status().Update(ctx, pod) Expect(err).To(BeNil(), "Failed to update pod status") Eventually( @@ -374,9 +369,8 @@ var _ = Describe("EphemeralRunner", func() { // Simulate init container failure without PodFailed phase. // This can happen when the kubelet has not yet transitioned the pod phase. - updatedPod := pod.DeepCopy() - updatedPod.Status.Phase = corev1.PodPending - updatedPod.Status.InitContainerStatuses = []corev1.ContainerStatus{ + pod.Status.Phase = corev1.PodPending + pod.Status.InitContainerStatuses = []corev1.ContainerStatus{ { Name: "setup", State: corev1.ContainerState{ @@ -388,7 +382,7 @@ var _ = Describe("EphemeralRunner", func() { }, }, } - err := k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) + err := k8sClient.Status().Update(ctx, pod) Expect(err).To(BeNil(), "Failed to update pod status") Eventually( @@ -428,9 +422,8 @@ var _ = Describe("EphemeralRunner", func() { return k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunner.Name, Namespace: ephemeralRunner.Namespace}, er) }, ephemeralRunnerTimeout, ephemeralRunnerInterval).Should(Succeed(), "failed to get ephemeral runner") - updatedER := er.DeepCopy() - updatedER.Status.JobID = "1" - err := k8sClient.Status().Patch(ctx, updatedER, client.MergeFrom(er)) + er.Status.JobID = "1" + err := k8sClient.Status().Update(ctx, er) Expect(err).To(BeNil(), "failed to update ephemeral runner status") Eventually(func() (string, error) { @@ -450,9 +443,8 @@ var _ = Describe("EphemeralRunner", func() { }, ephemeralRunnerTimeout, ephemeralRunnerInterval).Should(BeEquivalentTo(true)) // Simulate init container failure with job assigned - updatedPod := pod.DeepCopy() - updatedPod.Status.Phase = corev1.PodPending - updatedPod.Status.InitContainerStatuses = []corev1.ContainerStatus{ + pod.Status.Phase = corev1.PodPending + pod.Status.InitContainerStatuses = []corev1.ContainerStatus{ { Name: "setup", State: corev1.ContainerState{ @@ -463,7 +455,7 @@ var _ = Describe("EphemeralRunner", func() { }, }, } - err = k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) + err = k8sClient.Status().Update(ctx, pod) Expect(err).To(BeNil(), "Failed to update pod status") Eventually(func() bool { @@ -479,9 +471,8 @@ var _ = Describe("EphemeralRunner", func() { return k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunner.Name, Namespace: ephemeralRunner.Namespace}, er) }, ephemeralRunnerTimeout, ephemeralRunnerInterval).Should(Succeed(), "failed to get ephemeral runner") - updatedER := er.DeepCopy() - updatedER.Status.JobID = "1" - err := k8sClient.Status().Patch(ctx, updatedER, client.MergeFrom(er)) + er.Status.JobID = "1" + err := k8sClient.Status().Update(ctx, er) Expect(err).To(BeNil(), "failed to update ephemeral runner status") pod := new(corev1.Pod) @@ -496,9 +487,8 @@ var _ = Describe("EphemeralRunner", func() { ephemeralRunnerInterval, ).Should(Succeed(), "failed to get pod") - updatedPod := pod.DeepCopy() - updatedPod.Status.Phase = corev1.PodFailed - updatedPod.Status.ContainerStatuses = append(updatedPod.Status.ContainerStatuses, corev1.ContainerStatus{ + pod.Status.Phase = corev1.PodFailed + pod.Status.ContainerStatuses = append(pod.Status.ContainerStatuses, corev1.ContainerStatus{ Name: v1alpha1.EphemeralRunnerContainerName, State: corev1.ContainerState{ Terminated: &corev1.ContainerStateTerminated{ @@ -506,7 +496,7 @@ var _ = Describe("EphemeralRunner", func() { }, }, }) - err = k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) + err = k8sClient.Status().Update(ctx, pod) Expect(err).To(BeNil(), "Failed to update pod status") Eventually( @@ -533,9 +523,8 @@ var _ = Describe("EphemeralRunner", func() { ephemeralRunnerInterval, ).Should(Succeed(), "failed to get pod") - updatedPod := pod.DeepCopy() - updatedPod.Status.Phase = corev1.PodFailed - updatedPod.Status.ContainerStatuses = append(updatedPod.Status.ContainerStatuses, corev1.ContainerStatus{ + pod.Status.Phase = corev1.PodFailed + pod.Status.ContainerStatuses = append(pod.Status.ContainerStatuses, corev1.ContainerStatus{ Name: v1alpha1.EphemeralRunnerContainerName, State: corev1.ContainerState{ Terminated: &corev1.ContainerStateTerminated{ @@ -543,7 +532,7 @@ var _ = Describe("EphemeralRunner", func() { }, }, }) - err := k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) + err := k8sClient.Status().Update(ctx, pod) Expect(err).To(BeNil(), "Failed to update pod status") Eventually( @@ -579,10 +568,9 @@ var _ = Describe("EphemeralRunner", func() { ephemeralRunnerInterval, ).Should(Succeed(), "failed to get pod") - updatedPod := pod.DeepCopy() - updatedPod.Status.Phase = corev1.PodFailed + pod.Status.Phase = corev1.PodFailed oldPodUID := pod.UID - updatedPod.Status.ContainerStatuses = append(updatedPod.Status.ContainerStatuses, corev1.ContainerStatus{ + pod.Status.ContainerStatuses = append(pod.Status.ContainerStatuses, corev1.ContainerStatus{ Name: v1alpha1.EphemeralRunnerContainerName, State: corev1.ContainerState{ Terminated: &corev1.ContainerStateTerminated{ @@ -591,7 +579,7 @@ var _ = Describe("EphemeralRunner", func() { }, }) - err := k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) + err := k8sClient.Status().Update(ctx, pod) Expect(err).To(BeNil(), "Failed to update pod status") Eventually( @@ -951,9 +939,8 @@ var _ = Describe("EphemeralRunner", func() { ephemeralRunnerInterval, ).Should(BeEquivalentTo(true)) - updatedPod := pod.DeepCopy() - updatedPod.Status.Phase = corev1.PodRunning - err := k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) + pod.Status.Phase = corev1.PodRunning + err := k8sClient.Status().Update(ctx, pod) Expect(err).To(BeNil(), "failed to patch pod status") Consistently( @@ -988,8 +975,7 @@ var _ = Describe("EphemeralRunner", func() { ephemeralRunnerInterval, ).Should(Succeed(), "failed to get ephemeral runner pod") - updatedPod := pod.DeepCopy() - updatedPod.Status.ContainerStatuses = append(updatedPod.Status.ContainerStatuses, corev1.ContainerStatus{ + pod.Status.ContainerStatuses = append(pod.Status.ContainerStatuses, corev1.ContainerStatus{ Name: v1alpha1.EphemeralRunnerContainerName, State: corev1.ContainerState{ Terminated: &corev1.ContainerStateTerminated{ @@ -997,10 +983,10 @@ var _ = Describe("EphemeralRunner", func() { }, }, }) - err := k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) + err := k8sClient.Status().Update(ctx, pod) Expect(err).To(BeNil(), "Failed to update pod status") - return updatedPod + return pod } for i := range 5 { @@ -1079,14 +1065,13 @@ var _ = Describe("EphemeralRunner", func() { ephemeralRunnerInterval, ).Should(BeEquivalentTo(true)) - updatedPod := pod.DeepCopy() - updatedPod.Status.Phase = corev1.PodFailed - updatedPod.Status.Reason = "Evicted" - updatedPod.Status.ContainerStatuses = append(updatedPod.Status.ContainerStatuses, corev1.ContainerStatus{ + pod.Status.Phase = corev1.PodFailed + pod.Status.Reason = "Evicted" + pod.Status.ContainerStatuses = append(pod.Status.ContainerStatuses, corev1.ContainerStatus{ Name: v1alpha1.EphemeralRunnerContainerName, State: corev1.ContainerState{}, }) - err := k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) + err := k8sClient.Status().Update(ctx, pod) Expect(err).To(BeNil(), "failed to patch pod status") updated := new(v1alpha1.EphemeralRunner) @@ -1126,14 +1111,13 @@ var _ = Describe("EphemeralRunner", func() { ephemeralRunnerInterval, ).Should(BeEquivalentTo(true)) - updatedPod := pod.DeepCopy() - updatedPod.Status.Phase = corev1.PodFailed - updatedPod.Status.Reason = "OutOfpods" - updatedPod.Status.ContainerStatuses = append(updatedPod.Status.ContainerStatuses, corev1.ContainerStatus{ + pod.Status.Phase = corev1.PodFailed + pod.Status.Reason = "OutOfpods" + pod.Status.ContainerStatuses = append(pod.Status.ContainerStatuses, corev1.ContainerStatus{ Name: v1alpha1.EphemeralRunnerContainerName, State: corev1.ContainerState{}, }) - err := k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) + err := k8sClient.Status().Update(ctx, pod) Expect(err).To(BeNil(), "failed to patch pod status") updated := new(v1alpha1.EphemeralRunner) @@ -1173,8 +1157,7 @@ var _ = Describe("EphemeralRunner", func() { ).Should(BeEquivalentTo(true)) // first set phase to running - updatedPod := pod.DeepCopy() - updatedPod.Status.ContainerStatuses = append(updatedPod.Status.ContainerStatuses, corev1.ContainerStatus{ + pod.Status.ContainerStatuses = append(pod.Status.ContainerStatuses, corev1.ContainerStatus{ Name: v1alpha1.EphemeralRunnerContainerName, State: corev1.ContainerState{ Running: &corev1.ContainerStateRunning{ @@ -1182,8 +1165,8 @@ var _ = Describe("EphemeralRunner", func() { }, }, }) - updatedPod.Status.Phase = corev1.PodRunning - err := k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) + pod.Status.Phase = corev1.PodRunning + err := k8sClient.Status().Update(ctx, pod) Expect(err).To(BeNil()) Eventually( @@ -1199,9 +1182,8 @@ var _ = Describe("EphemeralRunner", func() { ).Should(BeEquivalentTo(v1alpha1.EphemeralRunnerPhaseRunning)) // set phase to succeeded - nextPod := updatedPod.DeepCopy() - nextPod.Status.Phase = corev1.PodSucceeded - err = k8sClient.Status().Patch(ctx, nextPod, client.MergeFrom(updatedPod)) + pod.Status.Phase = corev1.PodSucceeded + err = k8sClient.Status().Update(ctx, pod) Expect(err).To(BeNil()) Consistently( @@ -1276,8 +1258,7 @@ var _ = Describe("EphemeralRunner", func() { return true, nil }, ephemeralRunnerTimeout, ephemeralRunnerInterval).Should(BeEquivalentTo(true)) - updatedPod := pod.DeepCopy() - updatedPod.Status.ContainerStatuses = append(updatedPod.Status.ContainerStatuses, corev1.ContainerStatus{ + pod.Status.ContainerStatuses = append(pod.Status.ContainerStatuses, corev1.ContainerStatus{ Name: v1alpha1.EphemeralRunnerContainerName, State: corev1.ContainerState{ Terminated: &corev1.ContainerStateTerminated{ @@ -1285,7 +1266,7 @@ var _ = Describe("EphemeralRunner", func() { }, }, }) - err = k8sClient.Status().Patch(ctx, updatedPod, client.MergeFrom(pod)) + err = k8sClient.Status().Update(ctx, pod) Expect(err).To(BeNil(), "failed to update pod status") updated := new(v1alpha1.EphemeralRunner) @@ -1386,7 +1367,7 @@ var _ = Describe("EphemeralRunner", func() { ephemeralRunner.Spec.GitHubConfigURL = "http://example.com/org/repo" ephemeralRunner.Spec.Proxy = &v1alpha1.ProxyConfig{ HTTP: &v1alpha1.ProxyServerConfig{ - URL: proxy.URL, + Url: proxy.URL, CredentialSecretRef: "proxy-credentials", }, } @@ -1407,10 +1388,10 @@ var _ = Describe("EphemeralRunner", func() { ephemeralRunner := newExampleRunner("test-runner", autoScalingNS.Name, configSecret.Name) ephemeralRunner.Spec.Proxy = &v1alpha1.ProxyConfig{ HTTP: &v1alpha1.ProxyServerConfig{ - URL: "http://proxy.example.com:8080", + Url: "http://proxy.example.com:8080", }, HTTPS: &v1alpha1.ProxyServerConfig{ - URL: "http://proxy.example.com:8080", + Url: "http://proxy.example.com:8080", }, NoProxy: []string{"example.com"}, } diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go index 8d80ce0e55..3c3910240d 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go @@ -222,25 +222,10 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { ephemeralRunnerSetTestInterval, ).Should(BeEquivalentTo(0), "No EphemeralRunner should be created") - // Check if the status is initialized - Consistently( - func() (v1alpha1.EphemeralRunnerSetPhase, error) { - runnerSet := new(v1alpha1.EphemeralRunnerSet) - err := k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunnerSet.Name, Namespace: ephemeralRunnerSet.Namespace}, runnerSet) - if err != nil { - return "", err - } - - return runnerSet.Status.Phase, nil - }, - ephemeralRunnerSetTestTimeout, - ephemeralRunnerSetTestInterval, - ).Should(BeEquivalentTo(v1alpha1.EphemeralRunnerSetPhaseRunning), "EphemeralRunnerSet status should be running") - // Scaling up the EphemeralRunnerSet updated := created.DeepCopy() updated.Spec.Replicas = 5 - err := k8sClient.Patch(ctx, updated, client.MergeFrom(created)) + err := k8sClient.Update(ctx, updated) Expect(err).NotTo(HaveOccurred(), "failed to update EphemeralRunnerSet") // Check if the number of ephemeral runners are created @@ -275,21 +260,6 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { ephemeralRunnerSetTestTimeout, ephemeralRunnerSetTestInterval, ).Should(BeEquivalentTo(5), "5 EphemeralRunner should be created") - - // Check if the status stays running - Eventually( - func() (v1alpha1.EphemeralRunnerSetPhase, error) { - runnerSet := new(v1alpha1.EphemeralRunnerSet) - err := k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunnerSet.Name, Namespace: ephemeralRunnerSet.Namespace}, runnerSet) - if err != nil { - return "", err - } - - return runnerSet.Status.Phase, nil - }, - ephemeralRunnerSetTestTimeout, - ephemeralRunnerSetTestInterval, - ).Should(BeEquivalentTo(v1alpha1.EphemeralRunnerSetPhaseRunning), "EphemeralRunnerSet status should be running") }) }) @@ -302,7 +272,7 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { // Scale up the EphemeralRunnerSet updated := created.DeepCopy() updated.Spec.Replicas = 5 - err = k8sClient.Patch(ctx, updated, client.MergeFrom(created)) + err = k8sClient.Update(ctx, updated) Expect(err).NotTo(HaveOccurred(), "failed to update EphemeralRunnerSet") // Wait for the EphemeralRunnerSet to be scaled up @@ -1190,7 +1160,7 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { // Scale up the EphemeralRunnerSet updated := created.DeepCopy() updated.Spec.Replicas = 3 - err := k8sClient.Patch(ctx, updated, client.MergeFrom(created)) + err := k8sClient.Update(ctx, updated) Expect(err).NotTo(HaveOccurred(), "failed to update EphemeralRunnerSet replica count") runnerList := new(v1alpha1.EphemeralRunnerList) @@ -1546,11 +1516,11 @@ var _ = Describe("Test EphemeralRunnerSet controller with proxy settings", func( RunnerScaleSetID: 100, Proxy: &v1alpha1.ProxyConfig{ HTTP: &v1alpha1.ProxyServerConfig{ - URL: "http://proxy.example.com", + Url: "http://proxy.example.com", CredentialSecretRef: secretCredentials.Name, }, HTTPS: &v1alpha1.ProxyServerConfig{ - URL: "https://proxy.example.com", + Url: "https://proxy.example.com", CredentialSecretRef: secretCredentials.Name, }, NoProxy: []string{"example.com", "example.org"}, @@ -1729,7 +1699,7 @@ var _ = Describe("Test EphemeralRunnerSet controller with proxy settings", func( RunnerScaleSetID: 100, Proxy: &v1alpha1.ProxyConfig{ HTTP: &v1alpha1.ProxyServerConfig{ - URL: proxy.URL, + Url: proxy.URL, CredentialSecretRef: "proxy-credentials", }, }, diff --git a/controllers/actions.github.com/resourcebuilder.go b/controllers/actions.github.com/resourcebuilder.go index 68ce5cbc69..fd456b56f1 100644 --- a/controllers/actions.github.com/resourcebuilder.go +++ b/controllers/actions.github.com/resourcebuilder.go @@ -46,14 +46,14 @@ var commonLabelKeys = [...]string{ LabelKeyGitHubRepository, } -// AnnotationKeyIntegrityHash is used as a hash of the important fields +// annotationKeyIntegrityHash is used as a hash of the important fields // of each resource to determine if more drastic action should be taken. // // For example, annotations/labels are not something that should modify // the behavior of a resource, while the change in spec is. Therefore, // the spec hash should contain the spec fields in order to determine // modifications. -const AnnotationKeyIntegrityHash = "actions.github.com/integrity-hash" +const annotationKeyIntegrityHash = "actions.github.com/integrity-hash" const labelValueKubernetesPartOf = "gha-runner-scale-set" @@ -115,10 +115,6 @@ func (b *ResourceBuilder) setControllerReference(owner client.Object, object cli return ctrl.SetControllerReference(owner, object, b.Scheme) } -func autoscalingRunnerSetIntegrityHash(ars *v1alpha1.AutoscalingRunnerSet) string { - return hash.ComputeTemplateHash(&ars.Spec) -} - func (b *ResourceBuilder) newAutoscalingListener(autoscalingRunnerSet *v1alpha1.AutoscalingRunnerSet, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, namespace, image string, imagePullSecrets []corev1.LocalObjectReference) (*v1alpha1.AutoscalingListener, error) { runnerScaleSetID, err := strconv.Atoi(autoscalingRunnerSet.Annotations[runnerScaleSetIDAnnotationKey]) if err != nil { @@ -126,14 +122,13 @@ func (b *ResourceBuilder) newAutoscalingListener(autoscalingRunnerSet *v1alpha1. } effectiveMinRunners := 0 - if autoscalingRunnerSet.Spec.MinRunners != nil { - effectiveMinRunners = *autoscalingRunnerSet.Spec.MinRunners - } - effectiveMaxRunners := math.MaxInt32 if autoscalingRunnerSet.Spec.MaxRunners != nil { effectiveMaxRunners = *autoscalingRunnerSet.Spec.MaxRunners } + if autoscalingRunnerSet.Spec.MinRunners != nil { + effectiveMinRunners = *autoscalingRunnerSet.Spec.MinRunners + } spec := v1alpha1.AutoscalingListenerSpec{ GitHubConfigURL: autoscalingRunnerSet.Spec.GitHubConfigUrl, @@ -170,12 +165,12 @@ func (b *ResourceBuilder) newAutoscalingListener(autoscalingRunnerSet *v1alpha1. } annotations := map[string]string{ - AnnotationKeyIntegrityHash: spec.Hash(), + annotationKeyIntegrityHash: spec.Hash(), } if autoscalingRunnerSet.Spec.AutoscalingListenerMetadata != nil { labels = b.filterAndMergeLabels(autoscalingRunnerSet.Spec.AutoscalingListenerMetadata.Labels, labels) - annotations = b.filterAndMergeAnnotations(autoscalingRunnerSet.Spec.AutoscalingListenerMetadata.Annotations, annotations) + annotations = b.mergeAnnotations(autoscalingRunnerSet.Spec.AutoscalingListenerMetadata.Annotations, annotations) } autoscalingListener := &v1alpha1.AutoscalingListener{ @@ -283,7 +278,7 @@ func (b *ResourceBuilder) newScaleSetListenerConfig(autoscalingListener *v1alpha }, } - desiredSecret.Annotations[AnnotationKeyIntegrityHash] = scaleSetListenerConfigIntegrityHash(desiredSecret) + desiredSecret.Annotations[annotationKeyIntegrityHash] = scaleSetListenerConfigIntegrityHash(desiredSecret) if err := b.setControllerReference(autoscalingListener, desiredSecret); err != nil { return nil, fmt.Errorf("failed to set controller reference for listener config secret: %w", err) @@ -431,7 +426,7 @@ func (b *ResourceBuilder) newScaleSetListenerPod( Spec: podSpec, } - newRunnerScaleSetListenerPod.Annotations[AnnotationKeyIntegrityHash] = scaleSetListenerPodIntegrity( + newRunnerScaleSetListenerPod.Annotations[annotationKeyIntegrityHash] = scaleSetListenerPodIntegrity( newRunnerScaleSetListenerPod, autoscalingListener, podConfig, @@ -473,11 +468,11 @@ func scaleSetListenerPodIntegrity( d := data{ ListenerPodSpec: &pod.Spec, - AutoscalingListenerIntegrityHash: autoscalingListener.Annotations[AnnotationKeyIntegrityHash], - ConfigSecretIntegrityHash: podConfig.Annotations[AnnotationKeyIntegrityHash], - ServiceAccountIntegrityHash: serviceAccount.Annotations[AnnotationKeyIntegrityHash], - RoleIntegrityHash: role.Annotations[AnnotationKeyIntegrityHash], - RoleBindingIntegrityHash: roleBinding.Annotations[AnnotationKeyIntegrityHash], + AutoscalingListenerIntegrityHash: autoscalingListener.Annotations[annotationKeyIntegrityHash], + ConfigSecretIntegrityHash: podConfig.Annotations[annotationKeyIntegrityHash], + ServiceAccountIntegrityHash: serviceAccount.Annotations[annotationKeyIntegrityHash], + RoleIntegrityHash: role.Annotations[annotationKeyIntegrityHash], + RoleBindingIntegrityHash: roleBinding.Annotations[annotationKeyIntegrityHash], MetricsConfig: metricsConfig, } @@ -616,10 +611,10 @@ func (b *ResourceBuilder) newScaleSetListenerServiceAccount(autoscalingListener if autoscalingListener.Spec.ServiceAccountMetadata != nil { base.Labels = b.filterAndMergeLabels(autoscalingListener.Spec.ServiceAccountMetadata.Labels, base.Labels) - base.Annotations = b.filterAndMergeAnnotations(autoscalingListener.Spec.ServiceAccountMetadata.Annotations, base.Annotations) + base.Annotations = b.mergeAnnotations(autoscalingListener.Spec.ServiceAccountMetadata.Annotations, base.Annotations) } - base.Annotations[AnnotationKeyIntegrityHash] = scaleSetListenerServiceAccountIntegrityHash(base) + base.Annotations[annotationKeyIntegrityHash] = scaleSetListenerServiceAccountIntegrityHash(base) if err := b.setControllerReference(autoscalingListener, base); err != nil { return nil, fmt.Errorf("failed to set controller reference for listener service account: %w", err) @@ -655,7 +650,7 @@ func (b *ResourceBuilder) newScaleSetListenerRole(autoscalingListener *v1alpha1. annotations := make(map[string]string) if autoscalingListener.Spec.RoleMetadata != nil { labels = b.filterAndMergeLabels(autoscalingListener.Spec.RoleMetadata.Labels, labels) - annotations = b.filterAndMergeAnnotations(autoscalingListener.Spec.RoleMetadata.Annotations, nil) + annotations = b.mergeAnnotations(autoscalingListener.Spec.RoleMetadata.Annotations, nil) } newRole := &rbacv1.Role{ @@ -668,7 +663,7 @@ func (b *ResourceBuilder) newScaleSetListenerRole(autoscalingListener *v1alpha1. Rules: rulesForListenerRole([]string{autoscalingListener.Spec.EphemeralRunnerSetName}), } - newRole.Annotations[AnnotationKeyIntegrityHash] = scaleSetRoleIntegrityHash(newRole) + newRole.Annotations[annotationKeyIntegrityHash] = scaleSetRoleIntegrityHash(newRole) return newRole } @@ -723,7 +718,7 @@ func (b *ResourceBuilder) newScaleSetListenerRoleBinding(autoscalingListener *v1 Subjects: subjects, } - newRoleBinding.Annotations[AnnotationKeyIntegrityHash] = scaleSetListenerRoleBindingIntegrityHash(newRoleBinding) + newRoleBinding.Annotations[annotationKeyIntegrityHash] = scaleSetListenerRoleBindingIntegrityHash(newRoleBinding) return newRoleBinding } @@ -782,7 +777,7 @@ func (b *ResourceBuilder) newEphemeralRunnerSet(autoscalingRunnerSet *v1alpha1.A if autoscalingRunnerSet.Spec.EphemeralRunnerSetMetadata != nil { labels = b.filterAndMergeLabels(autoscalingRunnerSet.Spec.EphemeralRunnerSetMetadata.Labels, labels) - annotations = b.filterAndMergeAnnotations(autoscalingRunnerSet.Spec.EphemeralRunnerSetMetadata.Annotations, annotations) + annotations = b.mergeAnnotations(autoscalingRunnerSet.Spec.EphemeralRunnerSetMetadata.Annotations, annotations) } newEphemeralRunnerSet := &v1alpha1.EphemeralRunnerSet{ @@ -796,7 +791,7 @@ func (b *ResourceBuilder) newEphemeralRunnerSet(autoscalingRunnerSet *v1alpha1.A Spec: spec, } - newEphemeralRunnerSet.Annotations[AnnotationKeyIntegrityHash] = ephemeralRunnerSetIntegrityHash(newEphemeralRunnerSet) + newEphemeralRunnerSet.Annotations[annotationKeyIntegrityHash] = ephemeralRunnerSetIntegrityHash(newEphemeralRunnerSet) if err := b.setControllerReference(autoscalingRunnerSet, newEphemeralRunnerSet); err != nil { return nil, fmt.Errorf("failed to set controller reference for ephemeral runner set: %w", err) @@ -830,7 +825,7 @@ func (b *ResourceBuilder) newAutoscalingListenerProxySecret(autoscalingListener Data: data, } - newProxySecret.Annotations[AnnotationKeyIntegrityHash] = autoscalingListenerProxySecretIntegrityHash(newProxySecret) + newProxySecret.Annotations[annotationKeyIntegrityHash] = autoscalingListenerProxySecretIntegrityHash(newProxySecret) if err := b.setControllerReference(autoscalingListener, newProxySecret); err != nil { return nil, fmt.Errorf("failed to set controller reference for listener proxy secret: %w", err) @@ -862,7 +857,7 @@ func (b *ResourceBuilder) newEphemeralRunner(ephemeralRunnerSet *v1alpha1.Epheme if ephemeralRunnerSet.Spec.EphemeralRunnerMetadata != nil { labels = b.filterAndMergeLabels(ephemeralRunnerSet.Spec.EphemeralRunnerMetadata.Labels, labels) - annotations = b.filterAndMergeAnnotations(ephemeralRunnerSet.Spec.EphemeralRunnerMetadata.Annotations, annotations) + annotations = b.mergeAnnotations(ephemeralRunnerSet.Spec.EphemeralRunnerMetadata.Annotations, annotations) } ephemeralRunner := &v1alpha1.EphemeralRunner{ @@ -997,7 +992,7 @@ func (b *ResourceBuilder) newEphemeralRunnerSetProxySecret(ephemeralRunnerSet *v Data: data, } - runnerPodProxySecret.Annotations[AnnotationKeyIntegrityHash] = ephemeralRunnerSetProxySecretZIdentityHash(runnerPodProxySecret) + runnerPodProxySecret.Annotations[annotationKeyIntegrityHash] = ephemeralRunnerSetProxySecretZIdentityHash(runnerPodProxySecret) if err := b.setControllerReference(ephemeralRunnerSet, runnerPodProxySecret); err != nil { return nil, fmt.Errorf("failed to set controller reference for ephemeral runner set proxy secret: %w", err) @@ -1098,78 +1093,40 @@ func trimLabelValue(val string) string { return strings.Trim(val, "-_.") } -func (b *ResourceBuilder) filterLabels(k, v string) bool { - for _, prefix := range b.ExcludeLabelPropagationPrefixes { - if strings.HasPrefix(k, prefix) { - return true - } - } - return false -} - func (b *ResourceBuilder) filterAndMergeLabels(base, overwrite map[string]string) map[string]string { - return filterAndMergeMaps(base, overwrite, b.filterLabels) -} - -func filterAndMergeMaps(base, overwrite map[string]string, filter func(k, v string) bool) map[string]string { if base == nil && overwrite == nil { return nil } - var result map[string]string - if len(base) == 0 { - result = make(map[string]string) - } else { - result = maps.Clone(base) - } - if len(overwrite) > 0 { - maps.Copy(result, overwrite) - } - maps.DeleteFunc(result, filter) - return result -} -func (b *ResourceBuilder) filterAndMergeAnnotations(base, overwrite map[string]string) map[string]string { - if base == nil && overwrite == nil { - return nil - } - var result map[string]string - if len(base) == 0 { - result = make(map[string]string) - } else { - result = maps.Clone(base) + mergedLabels := make(map[string]string, len(base)) +base: + for k, v := range base { + for _, prefix := range b.ExcludeLabelPropagationPrefixes { + if strings.HasPrefix(k, prefix) { + continue base + } + } + mergedLabels[k] = v } +overwrite: for k, v := range overwrite { - if k == AnnotationKeyIntegrityHash { - continue + for _, prefix := range b.ExcludeLabelPropagationPrefixes { + if strings.HasPrefix(k, prefix) { + continue overwrite + } } - result[k] = v + mergedLabels[k] = v } - return result + return mergedLabels } -// compareAnnotations compares two maps of annotations, ignoring the integrity hash annotation. -func (b *ResourceBuilder) annotationsEqual(m1, m2 map[string]string) bool { - l1 := len(m1) - if _, ok := m1[AnnotationKeyIntegrityHash]; !ok { - l1++ - } - l2 := len(m2) - if _, ok := m2[AnnotationKeyIntegrityHash]; !ok { - l2++ - } - if l1 != l2 { - return false - } - - for k, v1 := range m1 { - if k == AnnotationKeyIntegrityHash { - continue - } - if v2, ok := m2[k]; !ok || v1 != v2 { - return false - } +func (b *ResourceBuilder) mergeAnnotations(base, overwrite map[string]string) map[string]string { + if base == nil && overwrite == nil { + return nil } - return true + base = maps.Clone(base) + maps.Copy(base, overwrite) + return base } diff --git a/controllers/actions.github.com/resourcebuilder_test.go b/controllers/actions.github.com/resourcebuilder_test.go index 206e610748..d08851173a 100644 --- a/controllers/actions.github.com/resourcebuilder_test.go +++ b/controllers/actions.github.com/resourcebuilder_test.go @@ -113,7 +113,7 @@ func TestMetadataPropagation(t *testing.T) { assert.Equal(t, labelValueKubernetesPartOf, ephemeralRunnerSet.Labels[LabelKeyKubernetesPartOf]) assert.Equal(t, "runner-set", ephemeralRunnerSet.Labels[LabelKeyKubernetesComponent]) assert.Equal(t, autoscalingRunnerSet.Labels[LabelKeyKubernetesVersion], ephemeralRunnerSet.Labels[LabelKeyKubernetesVersion]) - assert.NotEmpty(t, ephemeralRunnerSet.Annotations[AnnotationKeyIntegrityHash]) + assert.NotEmpty(t, ephemeralRunnerSet.Annotations[annotationKeyIntegrityHash]) assert.Equal(t, autoscalingRunnerSet.Name, ephemeralRunnerSet.Labels[LabelKeyGitHubScaleSetName]) assert.Equal(t, autoscalingRunnerSet.Namespace, ephemeralRunnerSet.Labels[LabelKeyGitHubScaleSetNamespace]) assert.Equal(t, "", ephemeralRunnerSet.Labels[LabelKeyGitHubEnterprise]) @@ -130,7 +130,7 @@ func TestMetadataPropagation(t *testing.T) { assert.Equal(t, labelValueKubernetesPartOf, listener.Labels[LabelKeyKubernetesPartOf]) assert.Equal(t, "runner-scale-set-listener", listener.Labels[LabelKeyKubernetesComponent]) assert.Equal(t, autoscalingRunnerSet.Labels[LabelKeyKubernetesVersion], listener.Labels[LabelKeyKubernetesVersion]) - assert.NotEmpty(t, ephemeralRunnerSet.Annotations[AnnotationKeyIntegrityHash]) + assert.NotEmpty(t, ephemeralRunnerSet.Annotations[annotationKeyIntegrityHash]) assert.Equal(t, autoscalingRunnerSet.Name, listener.Labels[LabelKeyGitHubScaleSetName]) assert.Equal(t, autoscalingRunnerSet.Namespace, listener.Labels[LabelKeyGitHubScaleSetNamespace]) assert.Equal(t, "", listener.Labels[LabelKeyGitHubEnterprise]) @@ -221,7 +221,7 @@ func TestEphemeralRunnerSetProxySecretZIdentityHash(t *testing.T) { }) require.NoError(t, err) - actualHash := proxySecret.Annotations[AnnotationKeyIntegrityHash] + actualHash := proxySecret.Annotations[annotationKeyIntegrityHash] assert.NotEmpty(t, actualHash) assert.Equal(t, ephemeralRunnerSetProxySecretZIdentityHash(proxySecret), actualHash) @@ -313,7 +313,7 @@ func TestOwnershipRelationships(t *testing.T) { runnerScaleSetIDAnnotationKey: "1", AnnotationKeyGitHubRunnerGroupName: "test-group", AnnotationKeyGitHubRunnerScaleSetName: "test-scale-set", - AnnotationKeyIntegrityHash: "test-hash", + annotationKeyIntegrityHash: "test-hash", }, }, Spec: v1alpha1.AutoscalingRunnerSetSpec{ diff --git a/controllers/actions.github.com/secretresolver/secret_resolver.go b/controllers/actions.github.com/secretresolver/secret_resolver.go index b9f9d9ccba..5f3e95612d 100644 --- a/controllers/actions.github.com/secretresolver/secret_resolver.go +++ b/controllers/actions.github.com/secretresolver/secret_resolver.go @@ -85,9 +85,9 @@ func (sr *SecretResolver) GetActionsService(ctx context.Context, obj object.Acti } if proxy.HTTP != nil { - u, err := url.Parse(proxy.HTTP.URL) + u, err := url.Parse(proxy.HTTP.Url) if err != nil { - return nil, fmt.Errorf("failed to parse proxy http url %q: %w", proxy.HTTP.URL, err) + return nil, fmt.Errorf("failed to parse proxy http url %q: %w", proxy.HTTP.Url, err) } if ref := proxy.HTTP.CredentialSecretRef; ref != "" { @@ -101,9 +101,9 @@ func (sr *SecretResolver) GetActionsService(ctx context.Context, obj object.Acti } if proxy.HTTPS != nil { - u, err := url.Parse(proxy.HTTPS.URL) + u, err := url.Parse(proxy.HTTPS.Url) if err != nil { - return nil, fmt.Errorf("failed to parse proxy https url %q: %w", proxy.HTTPS.URL, err) + return nil, fmt.Errorf("failed to parse proxy https url %q: %w", proxy.HTTPS.Url, err) } if ref := proxy.HTTPS.CredentialSecretRef; ref != "" { diff --git a/controllers/actions.github.com/utils.go b/controllers/actions.github.com/utils.go index da87eb7b21..a77b24ba17 100644 --- a/controllers/actions.github.com/utils.go +++ b/controllers/actions.github.com/utils.go @@ -1,8 +1,6 @@ package actionsgithubcom import ( - "encoding/json" - "k8s.io/apimachinery/pkg/util/rand" ) @@ -27,11 +25,3 @@ func RandStringRunes(n int) string { } return string(b) } - -func mustJSON(v any) string { - val, err := json.Marshal(v) - if err != nil { - panic(err) - } - return string(val) -} From 3de12eca0061455cde6a674960620ec39a6b0400 Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Fri, 10 Jul 2026 22:47:27 +0200 Subject: [PATCH 13/31] wip --- .../crds/actions.github.com_autoscalinglisteners.yaml | 10 ---------- .../crds/actions.github.com_ephemeralrunners.yaml | 4 ---- .../crds/actions.github.com_ephemeralrunnersets.yaml | 4 ---- .../crds/actions.github.com_autoscalinglisteners.yaml | 10 ---------- .../crds/actions.github.com_ephemeralrunners.yaml | 4 ---- .../crds/actions.github.com_ephemeralrunnersets.yaml | 4 ---- .../bases/actions.github.com_autoscalinglisteners.yaml | 10 ---------- .../crd/bases/actions.github.com_ephemeralrunners.yaml | 4 ---- .../bases/actions.github.com_ephemeralrunnersets.yaml | 4 ---- .../ephemeralrunnerset_controller.go | 10 ++++------ 10 files changed, 4 insertions(+), 60 deletions(-) diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalinglisteners.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalinglisteners.yaml index 20e57e3398..29cc9bb4bc 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalinglisteners.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalinglisteners.yaml @@ -51,10 +51,8 @@ spec: description: AutoscalingListenerSpec defines the desired state of AutoscalingListener properties: autoscalingRunnerSetName: - description: Required type: string autoscalingRunnerSetNamespace: - description: Required type: string configSecretMetadata: description: ResourceMeta carries metadata common to all internal @@ -70,13 +68,10 @@ spec: type: object type: object ephemeralRunnerSetName: - description: Required type: string githubConfigSecret: - description: Required type: string githubConfigUrl: - description: Required type: string githubServerTLS: properties: @@ -109,10 +104,8 @@ spec: type: object type: object image: - description: Required type: string imagePullSecrets: - description: Required items: description: |- LocalObjectReference contains enough information to let you locate the @@ -131,7 +124,6 @@ spec: x-kubernetes-map-type: atomic type: array maxRunners: - description: Required minimum: 0 type: integer metrics: @@ -183,7 +175,6 @@ spec: type: object type: object minRunners: - description: Required minimum: 0 type: integer proxy: @@ -236,7 +227,6 @@ spec: type: object type: object runnerScaleSetId: - description: Required type: integer serviceAccountMetadata: description: ResourceMeta carries metadata common to all internal diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunners.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunners.yaml index 3cd90148ca..a174d751d4 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunners.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunners.yaml @@ -8290,10 +8290,6 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object - required: - - githubConfigSecret - - githubConfigUrl - - runnerScaleSetId type: object status: description: EphemeralRunnerStatus defines the observed state of EphemeralRunner diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml index fa706e3ba6..2d17def801 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml @@ -8284,10 +8284,6 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object - required: - - githubConfigSecret - - githubConfigUrl - - runnerScaleSetId type: object patchID: description: PatchID is the unique identifier for the patch issued by the listener app diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalinglisteners.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalinglisteners.yaml index 20e57e3398..29cc9bb4bc 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalinglisteners.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalinglisteners.yaml @@ -51,10 +51,8 @@ spec: description: AutoscalingListenerSpec defines the desired state of AutoscalingListener properties: autoscalingRunnerSetName: - description: Required type: string autoscalingRunnerSetNamespace: - description: Required type: string configSecretMetadata: description: ResourceMeta carries metadata common to all internal @@ -70,13 +68,10 @@ spec: type: object type: object ephemeralRunnerSetName: - description: Required type: string githubConfigSecret: - description: Required type: string githubConfigUrl: - description: Required type: string githubServerTLS: properties: @@ -109,10 +104,8 @@ spec: type: object type: object image: - description: Required type: string imagePullSecrets: - description: Required items: description: |- LocalObjectReference contains enough information to let you locate the @@ -131,7 +124,6 @@ spec: x-kubernetes-map-type: atomic type: array maxRunners: - description: Required minimum: 0 type: integer metrics: @@ -183,7 +175,6 @@ spec: type: object type: object minRunners: - description: Required minimum: 0 type: integer proxy: @@ -236,7 +227,6 @@ spec: type: object type: object runnerScaleSetId: - description: Required type: integer serviceAccountMetadata: description: ResourceMeta carries metadata common to all internal diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunners.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunners.yaml index 3cd90148ca..a174d751d4 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunners.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunners.yaml @@ -8290,10 +8290,6 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object - required: - - githubConfigSecret - - githubConfigUrl - - runnerScaleSetId type: object status: description: EphemeralRunnerStatus defines the observed state of EphemeralRunner diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml index fa706e3ba6..2d17def801 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml @@ -8284,10 +8284,6 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object - required: - - githubConfigSecret - - githubConfigUrl - - runnerScaleSetId type: object patchID: description: PatchID is the unique identifier for the patch issued by the listener app diff --git a/config/crd/bases/actions.github.com_autoscalinglisteners.yaml b/config/crd/bases/actions.github.com_autoscalinglisteners.yaml index 20e57e3398..29cc9bb4bc 100644 --- a/config/crd/bases/actions.github.com_autoscalinglisteners.yaml +++ b/config/crd/bases/actions.github.com_autoscalinglisteners.yaml @@ -51,10 +51,8 @@ spec: description: AutoscalingListenerSpec defines the desired state of AutoscalingListener properties: autoscalingRunnerSetName: - description: Required type: string autoscalingRunnerSetNamespace: - description: Required type: string configSecretMetadata: description: ResourceMeta carries metadata common to all internal @@ -70,13 +68,10 @@ spec: type: object type: object ephemeralRunnerSetName: - description: Required type: string githubConfigSecret: - description: Required type: string githubConfigUrl: - description: Required type: string githubServerTLS: properties: @@ -109,10 +104,8 @@ spec: type: object type: object image: - description: Required type: string imagePullSecrets: - description: Required items: description: |- LocalObjectReference contains enough information to let you locate the @@ -131,7 +124,6 @@ spec: x-kubernetes-map-type: atomic type: array maxRunners: - description: Required minimum: 0 type: integer metrics: @@ -183,7 +175,6 @@ spec: type: object type: object minRunners: - description: Required minimum: 0 type: integer proxy: @@ -236,7 +227,6 @@ spec: type: object type: object runnerScaleSetId: - description: Required type: integer serviceAccountMetadata: description: ResourceMeta carries metadata common to all internal diff --git a/config/crd/bases/actions.github.com_ephemeralrunners.yaml b/config/crd/bases/actions.github.com_ephemeralrunners.yaml index 3cd90148ca..a174d751d4 100644 --- a/config/crd/bases/actions.github.com_ephemeralrunners.yaml +++ b/config/crd/bases/actions.github.com_ephemeralrunners.yaml @@ -8290,10 +8290,6 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object - required: - - githubConfigSecret - - githubConfigUrl - - runnerScaleSetId type: object status: description: EphemeralRunnerStatus defines the observed state of EphemeralRunner diff --git a/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml b/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml index fa706e3ba6..2d17def801 100644 --- a/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml +++ b/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml @@ -8284,10 +8284,6 @@ spec: It is used to identify which vault integration should be used to resolve secrets. type: string type: object - required: - - githubConfigSecret - - githubConfigUrl - - runnerScaleSetId type: object patchID: description: PatchID is the unique identifier for the patch issued by the listener app diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller.go b/controllers/actions.github.com/ephemeralrunnerset_controller.go index ae5c6359b4..df214e51e8 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller.go @@ -135,7 +135,7 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R // If hash spec has changed, delete idle ephemeral runners // in order to apply the change to the runners that did not yet receive a job. ephemeralRunnerIntegrityHash := ephemeralRunnerSetIntegrityHash(&ephemeralRunnerSet) - if ephemeralRunnerSet.Annotations[AnnotationKeyIntegrityHash] != ephemeralRunnerIntegrityHash { + if ephemeralRunnerSet.Annotations[annotationKeyIntegrityHash] != ephemeralRunnerIntegrityHash { log.Info("EphemeralRunnerSpec has changed, deleting idle ephemeral runners to apply the new spec") if _, err := r.cleanUpEphemeralRunners(ctx, &ephemeralRunnerSet, log); err != nil { log.Error(err, "Failed to clean up EphemeralRunners") @@ -152,7 +152,7 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R if ephemeralRunnerSet.Annotations == nil { ephemeralRunnerSet.Annotations = make(map[string]string) } - ephemeralRunnerSet.Annotations[AnnotationKeyIntegrityHash] = ephemeralRunnerIntegrityHash + ephemeralRunnerSet.Annotations[annotationKeyIntegrityHash] = ephemeralRunnerIntegrityHash if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original)); err != nil { log.Error(err, "Failed to update ephemeral runner set with new spec hash") return ctrl.Result{}, err @@ -254,9 +254,7 @@ func (r *EphemeralRunnerSetReconciler) updateStatus(ctx context.Context, ephemer default: phase = ephemeralRunnerSet.Status.Phase } - desiredStatus := v1alpha1.EphemeralRunnerSetStatus{ - Phase: phase, - } + desiredStatus := v1alpha1.EphemeralRunnerSetStatus{Phase: phase} // Update the status if needed. if ephemeralRunnerSet.Status != desiredStatus { @@ -468,7 +466,7 @@ func (r *EphemeralRunnerSetReconciler) reconcileEphemeralRunnerSetProxySecret(ct dataModified := !maps.EqualFunc(proxySecret.Data, desiredRunnerSetProxy.Data, bytes.Equal) desiredLabels := r.filterAndMergeLabels(proxySecret.Labels, desiredRunnerSetProxy.Labels) labelsModified := !maps.Equal(proxySecret.Labels, desiredLabels) - desiredAnnotations := r.filterAndMergeAnnotations(proxySecret.Annotations, desiredRunnerSetProxy.Annotations) + desiredAnnotations := r.mergeAnnotations(proxySecret.Annotations, desiredRunnerSetProxy.Annotations) annotationsModified := !maps.Equal(proxySecret.Annotations, desiredAnnotations) if dataModified || labelsModified || annotationsModified { updatedProxySecret := proxySecret.DeepCopy() From 32a8d208c826094eb282ec8eed20df1da8bf4ccc Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Sat, 11 Jul 2026 00:55:57 +0200 Subject: [PATCH 14/31] revert optional --- .../v1alpha1/autoscalingrunnerset_types.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go b/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go index 11c9edd915..91fd6e60b2 100644 --- a/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go +++ b/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go @@ -49,10 +49,10 @@ type AutoscalingRunnerSet struct { // AutoscalingRunnerSetSpec defines the desired state of AutoscalingRunnerSet type AutoscalingRunnerSetSpec struct { - // Required + // +optional GitHubConfigUrl string `json:"githubConfigUrl,omitempty"` - // Required + // +optional GitHubConfigSecret string `json:"githubConfigSecret,omitempty"` // +optional @@ -73,7 +73,7 @@ type AutoscalingRunnerSetSpec struct { // +optional VaultConfig *VaultConfig `json:"vaultConfig,omitempty"` - // Required + // +optional Template corev1.PodTemplateSpec `json:"template,omitempty"` // +optional @@ -116,7 +116,7 @@ type AutoscalingRunnerSetSpec struct { } type TLSConfig struct { - // Required + // +optional CertificateFrom *TLSCertificateSource `json:"certificateFrom,omitempty"` } @@ -153,7 +153,7 @@ func (c *TLSConfig) ToCertPool(keyFetcher func(name, key string) ([]byte, error) } type TLSCertificateSource struct { - // Required + // +optional ConfigMapKeyRef *corev1.ConfigMapKeySelector `json:"configMapKeyRef,omitempty"` } @@ -254,7 +254,7 @@ func (c *ProxyConfig) ProxyFunc(secretFetcher func(string) (*corev1.Secret, erro } type ProxyServerConfig struct { - // Required + // +optional Url string `json:"url,omitempty"` // +optional From 2148a623f5bf97ba59601e74c76ab5bf039ef0a4 Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Sat, 11 Jul 2026 01:54:24 +0200 Subject: [PATCH 15/31] wip --- .../v1alpha1/autoscalingrunnerset_types.go | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go b/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go index 91fd6e60b2..dd1bce7fa1 100644 --- a/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go +++ b/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go @@ -36,14 +36,22 @@ import ( // +kubebuilder:subresource:status // +kubebuilder:printcolumn:JSONPath=".spec.minRunners",name=Minimum Runners,type=integer // +kubebuilder:printcolumn:JSONPath=".spec.maxRunners",name=Maximum Runners,type=integer +// +kubebuilder:printcolumn:JSONPath=".status.currentRunners",name=Current Runners,type=integer // +kubebuilder:printcolumn:JSONPath=".status.phase",name=Phase,type=string +// +kubebuilder:printcolumn:JSONPath=".status.pendingEphemeralRunners",name=Pending Runners,type=integer +// +kubebuilder:printcolumn:JSONPath=".status.runningEphemeralRunners",name=Running Runners,type=integer +// +kubebuilder:printcolumn:JSONPath=".status.finishedEphemeralRunners",name=Finished Runners,type=integer +// +kubebuilder:printcolumn:JSONPath=".status.deletingEphemeralRunners",name=Deleting Runners,type=integer // AutoscalingRunnerSet is the Schema for the autoscalingrunnersets API type AutoscalingRunnerSet struct { - metav1.TypeMeta `json:",inline"` + metav1.TypeMeta `json:",inline"` + // +optional metav1.ObjectMeta `json:"metadata,omitempty"` - Spec AutoscalingRunnerSetSpec `json:"spec,omitempty"` + // +optional + Spec AutoscalingRunnerSetSpec `json:"spec,omitempty"` + // +optional Status AutoscalingRunnerSetStatus `json:"status,omitempty"` } @@ -116,7 +124,7 @@ type AutoscalingRunnerSetSpec struct { } type TLSConfig struct { - // +optional + // Required CertificateFrom *TLSCertificateSource `json:"certificateFrom,omitempty"` } @@ -153,7 +161,7 @@ func (c *TLSConfig) ToCertPool(keyFetcher func(name, key string) ([]byte, error) } type TLSCertificateSource struct { - // +optional + // Required ConfigMapKeyRef *corev1.ConfigMapKeySelector `json:"configMapKeyRef,omitempty"` } @@ -254,7 +262,7 @@ func (c *ProxyConfig) ProxyFunc(secretFetcher func(string) (*corev1.Secret, erro } type ProxyServerConfig struct { - // +optional + // Required Url string `json:"url,omitempty"` // +optional From 3a6ce796724b4d81219a496441577eb0d2ac4825 Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Mon, 13 Jul 2026 11:25:37 +0200 Subject: [PATCH 16/31] wip --- .../autoscalingrunnerset_controller.go | 16 +-- .../autoscalingrunnerset_controller_test.go | 105 +++++++++++++++--- .../ephemeralrunnerset_controller.go | 17 ++- .../ephemeralrunnerset_controller_test.go | 33 +++++- 4 files changed, 138 insertions(+), 33 deletions(-) diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller.go b/controllers/actions.github.com/autoscalingrunnerset_controller.go index d1913f27c6..40be4d25bf 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller.go @@ -295,17 +295,7 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl // When runners are actively processing jobs, defer the spec update: // delete the listener to stop accepting new jobs, but leave the ERS // (and its running pods) untouched until all jobs have drained. - var ephemeralRunnerList v1alpha1.EphemeralRunnerList - if err := r.List(ctx, &ephemeralRunnerList, - client.InNamespace(ephemeralRunnerSet.Namespace), - client.MatchingFields{resourceOwnerKey: ephemeralRunnerSet.Name}, - ); err != nil { - log.Error(err, "Failed to list ephemeral runners") - return ctrl.Result{}, err - } - - ephemeralRunnersByState := newEphemeralRunnersByStates(&ephemeralRunnerList) - if len(ephemeralRunnersByState.running)+len(ephemeralRunnersByState.pending) > 0 { + if ephemeralRunnerSet.Status.RunningEphemeralRunners+ephemeralRunnerSet.Status.PendingEphemeralRunners > 0 { log.Info("Ephemeral runner set spec changed but runners are still active; deleting listener to stop new jobs") if _, err := r.cleanupListener(ctx, &autoscalingRunnerSet, log); err != nil { log.Error(err, "Failed to clean up listener while waiting for runners to drain") @@ -448,7 +438,9 @@ func (r *AutoscalingRunnerSetReconciler) updateStatus(ctx context.Context, autos } original := autoscalingRunnerSet.DeepCopy() - autoscalingRunnerSet.Status.Phase = phase + if phaseDiff { + autoscalingRunnerSet.Status.Phase = phase + } if err := r.Status().Patch(ctx, autoscalingRunnerSet, client.MergeFrom(original)); err != nil { log.Error(err, "Failed to patch autoscaling runner set status") diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go index e7bb6f0804..55ebe5c6ae 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go @@ -871,21 +871,35 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { // Emulate running and pending jobs runnerSet := runnerSetList.Items[0] activeRunnerSet := runnerSet.DeepCopy() - for _, phase := range []v1alpha1.EphemeralRunnerPhase{ - v1alpha1.EphemeralRunnerPhaseRunning, - v1alpha1.EphemeralRunnerPhasePending, - } { - runner, err := controller.newEphemeralRunner(activeRunnerSet) - Expect(err).NotTo(HaveOccurred(), "Failed to create active runner") - err = k8sClient.Create(ctx, runner) - Expect(err).NotTo(HaveOccurred(), "Failed to create active runner") - - updatedRunner := runner.DeepCopy() - updatedRunner.Status.Phase = phase - err = k8sClient.Status().Patch(ctx, updatedRunner, client.MergeFrom(runner)) - Expect(err).NotTo(HaveOccurred(), "Failed to patch active runner status") + activeRunnerSet.Status.CurrentReplicas = 6 + activeRunnerSet.Status.FailedEphemeralRunners = 1 + activeRunnerSet.Status.RunningEphemeralRunners = 2 + activeRunnerSet.Status.PendingEphemeralRunners = 3 + + desiredStatus := v1alpha1.AutoscalingRunnerSetStatus{ + CurrentRunners: activeRunnerSet.Status.CurrentReplicas, + Phase: v1alpha1.AutoscalingRunnerSetPhaseRunning, + PendingEphemeralRunners: activeRunnerSet.Status.PendingEphemeralRunners, + RunningEphemeralRunners: activeRunnerSet.Status.RunningEphemeralRunners, + FailedEphemeralRunners: activeRunnerSet.Status.FailedEphemeralRunners, } + err = k8sClient.Status().Patch(ctx, activeRunnerSet, client.MergeFrom(&runnerSet)) + Expect(err).NotTo(HaveOccurred(), "Failed to patch runner set status") + + Eventually( + func() (v1alpha1.AutoscalingRunnerSetStatus, error) { + updated := new(v1alpha1.AutoscalingRunnerSet) + err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, updated) + if err != nil { + return v1alpha1.AutoscalingRunnerSetStatus{}, fmt.Errorf("failed to get AutoScalingRunnerSet: %w", err) + } + return updated.Status, nil + }, + autoscalingRunnerSetTestTimeout, + autoscalingRunnerSetTestInterval, + ).Should(BeEquivalentTo(desiredStatus), "AutoScalingRunnerSet status should be updated") + // Patch the AutoScalingRunnerSet image which should trigger // the recreation of the Listener and EphemeralRunnerSet patched := autoscalingRunnerSet.DeepCopy() @@ -928,6 +942,71 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { }) }) + It("Should update Status on EphemeralRunnerSet status Update", func() { + ars := new(v1alpha1.AutoscalingRunnerSet) + Eventually( + func() (bool, error) { + err := k8sClient.Get( + ctx, + client.ObjectKey{ + Name: autoscalingRunnerSet.Name, + Namespace: autoscalingRunnerSet.Namespace, + }, + ars, + ) + if err != nil { + return false, err + } + return true, nil + }, + autoscalingRunnerSetTestTimeout, + autoscalingRunnerSetTestInterval, + ).Should(BeTrue(), "AutoscalingRunnerSet should be created") + + runnerSetList := new(v1alpha1.EphemeralRunnerSetList) + Eventually( + func() (int, error) { + err := k8sClient.List(ctx, runnerSetList, client.InNamespace(ars.Namespace)) + if err != nil { + return 0, err + } + return len(runnerSetList.Items), nil + }, + autoscalingRunnerSetTestTimeout, + autoscalingRunnerSetTestInterval, + ).Should(BeEquivalentTo(1), "Failed to fetch runner set list") + + runnerSet := runnerSetList.Items[0] + statusUpdate := runnerSet.DeepCopy() + statusUpdate.Status.CurrentReplicas = 6 + statusUpdate.Status.FailedEphemeralRunners = 1 + statusUpdate.Status.RunningEphemeralRunners = 2 + statusUpdate.Status.PendingEphemeralRunners = 3 + + desiredStatus := v1alpha1.AutoscalingRunnerSetStatus{ + CurrentRunners: statusUpdate.Status.CurrentReplicas, + Phase: v1alpha1.AutoscalingRunnerSetPhaseRunning, + PendingEphemeralRunners: statusUpdate.Status.PendingEphemeralRunners, + RunningEphemeralRunners: statusUpdate.Status.RunningEphemeralRunners, + FailedEphemeralRunners: statusUpdate.Status.FailedEphemeralRunners, + } + + err := k8sClient.Status().Patch(ctx, statusUpdate, client.MergeFrom(&runnerSet)) + Expect(err).NotTo(HaveOccurred(), "Failed to patch runner set status") + + Eventually( + func() (v1alpha1.AutoscalingRunnerSetStatus, error) { + updated := new(v1alpha1.AutoscalingRunnerSet) + err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, updated) + if err != nil { + return v1alpha1.AutoscalingRunnerSetStatus{}, fmt.Errorf("failed to get AutoScalingRunnerSet: %w", err) + } + return updated.Status, nil + }, + autoscalingRunnerSetTestTimeout, + autoscalingRunnerSetTestInterval, + ).Should(BeEquivalentTo(desiredStatus), "AutoScalingRunnerSet status should be updated") + }) }) var _ = Describe("Test AutoScalingController updates", Ordered, func() { diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller.go b/controllers/actions.github.com/ephemeralrunnerset_controller.go index df214e51e8..54aa85ae90 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller.go @@ -245,6 +245,8 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R } func (r *EphemeralRunnerSetReconciler) updateStatus(ctx context.Context, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, state *ephemeralRunnersByState, log logr.Logger) error { + original := ephemeralRunnerSet.DeepCopy() + total := state.scaleTotal() var phase v1alpha1.EphemeralRunnerSetPhase switch { case len(state.outdated) > 0: @@ -254,17 +256,22 @@ func (r *EphemeralRunnerSetReconciler) updateStatus(ctx context.Context, ephemer default: phase = ephemeralRunnerSet.Status.Phase } - desiredStatus := v1alpha1.EphemeralRunnerSetStatus{Phase: phase} + desiredStatus := v1alpha1.EphemeralRunnerSetStatus{ + CurrentReplicas: total, + Phase: phase, + PendingEphemeralRunners: len(state.pending), + RunningEphemeralRunners: len(state.running), + FailedEphemeralRunners: len(state.failed), + } // Update the status if needed. if ephemeralRunnerSet.Status != desiredStatus { - updated := ephemeralRunnerSet.DeepCopy() - updated.Status = desiredStatus - if err := r.Status().Patch(ctx, updated, client.MergeFrom(ephemeralRunnerSet)); err != nil { + ephemeralRunnerSet.Status = desiredStatus + if err := r.Status().Patch(ctx, ephemeralRunnerSet, client.MergeFrom(original)); err != nil { log.Error(err, "Failed to update EphemeralRunnerSet status") return err } - log.Info("Updated EphemeralRunnerSet status", "status", updated.Status) + log.Info("Updated EphemeralRunnerSet status", "status", ephemeralRunnerSet.Status) } return nil diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go index 3c3910240d..3533e0dd4c 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go @@ -222,6 +222,21 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { ephemeralRunnerSetTestInterval, ).Should(BeEquivalentTo(0), "No EphemeralRunner should be created") + // Check if the status stay 0 + Consistently( + func() (int, error) { + runnerSet := new(v1alpha1.EphemeralRunnerSet) + err := k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunnerSet.Name, Namespace: ephemeralRunnerSet.Namespace}, runnerSet) + if err != nil { + return -1, err + } + + return int(runnerSet.Status.CurrentReplicas), nil + }, + ephemeralRunnerSetTestTimeout, + ephemeralRunnerSetTestInterval, + ).Should(BeEquivalentTo(0), "EphemeralRunnerSet status should be 0") + // Scaling up the EphemeralRunnerSet updated := created.DeepCopy() updated.Spec.Replicas = 5 @@ -1243,7 +1258,11 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { ).Should(BeTrue(), "Failed to eventually update to one pending, one running and one failed") desiredStatus := v1alpha1.EphemeralRunnerSetStatus{ - Phase: v1alpha1.EphemeralRunnerSetPhaseRunning, + Phase: v1alpha1.EphemeralRunnerSetPhaseRunning, + CurrentReplicas: 3, + PendingEphemeralRunners: 1, + RunningEphemeralRunners: 1, + FailedEphemeralRunners: 1, } Eventually( func() (v1alpha1.EphemeralRunnerSetStatus, error) { @@ -1282,7 +1301,11 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { ).Should(BeEquivalentTo(1), "Failed to eventually scale down") desiredStatus = v1alpha1.EphemeralRunnerSetStatus{ - Phase: v1alpha1.EphemeralRunnerSetPhaseRunning, + CurrentReplicas: 1, + PendingEphemeralRunners: 0, + RunningEphemeralRunners: 0, + FailedEphemeralRunners: 1, + Phase: v1alpha1.EphemeralRunnerSetPhaseRunning, } Eventually( @@ -1302,7 +1325,11 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { Expect(err).To(BeNil(), "Failed to delete failed ephemeral runner") desiredStatus = v1alpha1.EphemeralRunnerSetStatus{ - Phase: v1alpha1.EphemeralRunnerSetPhaseRunning, + CurrentReplicas: 0, + PendingEphemeralRunners: 0, + RunningEphemeralRunners: 0, + FailedEphemeralRunners: 0, + Phase: v1alpha1.EphemeralRunnerSetPhaseRunning, } Eventually( func() (v1alpha1.EphemeralRunnerSetStatus, error) { From 195f9a4e85a9532e44fc1270f69a116e8f62092f Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Mon, 13 Jul 2026 12:58:55 +0200 Subject: [PATCH 17/31] wip --- ...ions.github.com_autoscalingrunnersets.yaml | 19 ++- ...ions.github.com_autoscalingrunnersets.yaml | 19 ++- ...ions.github.com_autoscalingrunnersets.yaml | 19 ++- .../autoscalingrunnerset_controller.go | 16 +-- .../autoscalingrunnerset_controller_test.go | 116 +----------------- .../ephemeralrunnerset_controller.go | 7 +- .../ephemeralrunnerset_controller_test.go | 28 ++--- 7 files changed, 60 insertions(+), 164 deletions(-) diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml index 1f4b63f32f..a2dfd46d9f 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml @@ -21,9 +21,24 @@ spec: - jsonPath: .spec.maxRunners name: Maximum Runners type: integer + - jsonPath: .status.currentRunners + name: Current Runners + type: integer - jsonPath: .status.phase name: Phase type: string + - jsonPath: .status.pendingEphemeralRunners + name: Pending Runners + type: integer + - jsonPath: .status.runningEphemeralRunners + name: Running Runners + type: integer + - jsonPath: .status.finishedEphemeralRunners + name: Finished Runners + type: integer + - jsonPath: .status.deletingEphemeralRunners + name: Deleting Runners + type: integer name: v1alpha1 schema: openAPIV3Schema: @@ -98,10 +113,8 @@ spec: type: object type: object githubConfigSecret: - description: Required type: string githubConfigUrl: - description: Required type: string githubServerTLS: properties: @@ -8376,7 +8389,7 @@ spec: runnerScaleSetName: type: string template: - description: Required + description: PodTemplateSpec describes the data a pod should have when created from a template properties: metadata: description: |- diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml index 1f4b63f32f..a2dfd46d9f 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml @@ -21,9 +21,24 @@ spec: - jsonPath: .spec.maxRunners name: Maximum Runners type: integer + - jsonPath: .status.currentRunners + name: Current Runners + type: integer - jsonPath: .status.phase name: Phase type: string + - jsonPath: .status.pendingEphemeralRunners + name: Pending Runners + type: integer + - jsonPath: .status.runningEphemeralRunners + name: Running Runners + type: integer + - jsonPath: .status.finishedEphemeralRunners + name: Finished Runners + type: integer + - jsonPath: .status.deletingEphemeralRunners + name: Deleting Runners + type: integer name: v1alpha1 schema: openAPIV3Schema: @@ -98,10 +113,8 @@ spec: type: object type: object githubConfigSecret: - description: Required type: string githubConfigUrl: - description: Required type: string githubServerTLS: properties: @@ -8376,7 +8389,7 @@ spec: runnerScaleSetName: type: string template: - description: Required + description: PodTemplateSpec describes the data a pod should have when created from a template properties: metadata: description: |- diff --git a/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml b/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml index 1f4b63f32f..a2dfd46d9f 100644 --- a/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml +++ b/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml @@ -21,9 +21,24 @@ spec: - jsonPath: .spec.maxRunners name: Maximum Runners type: integer + - jsonPath: .status.currentRunners + name: Current Runners + type: integer - jsonPath: .status.phase name: Phase type: string + - jsonPath: .status.pendingEphemeralRunners + name: Pending Runners + type: integer + - jsonPath: .status.runningEphemeralRunners + name: Running Runners + type: integer + - jsonPath: .status.finishedEphemeralRunners + name: Finished Runners + type: integer + - jsonPath: .status.deletingEphemeralRunners + name: Deleting Runners + type: integer name: v1alpha1 schema: openAPIV3Schema: @@ -98,10 +113,8 @@ spec: type: object type: object githubConfigSecret: - description: Required type: string githubConfigUrl: - description: Required type: string githubServerTLS: properties: @@ -8376,7 +8389,7 @@ spec: runnerScaleSetName: type: string template: - description: Required + description: PodTemplateSpec describes the data a pod should have when created from a template properties: metadata: description: |- diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller.go b/controllers/actions.github.com/autoscalingrunnerset_controller.go index 40be4d25bf..e48f75ff15 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller.go @@ -292,18 +292,6 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl } if ephemeralRunnerSet.Annotations[annotationKeyIntegrityHash] != desired.Annotations[annotationKeyIntegrityHash] { - // When runners are actively processing jobs, defer the spec update: - // delete the listener to stop accepting new jobs, but leave the ERS - // (and its running pods) untouched until all jobs have drained. - if ephemeralRunnerSet.Status.RunningEphemeralRunners+ephemeralRunnerSet.Status.PendingEphemeralRunners > 0 { - log.Info("Ephemeral runner set spec changed but runners are still active; deleting listener to stop new jobs") - if _, err := r.cleanupListener(ctx, &autoscalingRunnerSet, log); err != nil { - log.Error(err, "Failed to clean up listener while waiting for runners to drain") - return ctrl.Result{}, err - } - return ctrl.Result{RequeueAfter: 1 * time.Second}, nil - } - original := ephemeralRunnerSet.DeepCopy() ephemeralRunnerSet.Spec.EphemeralRunnerMetadata = desired.Spec.EphemeralRunnerMetadata ephemeralRunnerSet.Spec.EphemeralRunnerSpec = desired.Spec.EphemeralRunnerSpec @@ -438,9 +426,7 @@ func (r *AutoscalingRunnerSetReconciler) updateStatus(ctx context.Context, autos } original := autoscalingRunnerSet.DeepCopy() - if phaseDiff { - autoscalingRunnerSet.Status.Phase = phase - } + autoscalingRunnerSet.Status.Phase = phase if err := r.Status().Patch(ctx, autoscalingRunnerSet, client.MergeFrom(original)); err != nil { log.Error(err, "Failed to patch autoscaling runner set status") diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go index 55ebe5c6ae..11a03dc482 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go @@ -837,111 +837,6 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { }) }) - Context("When updating an AutoscalingRunnerSet with running or pending jobs", func() { - It("It should wait for running and pending jobs to finish before applying the update.", func() { - // Wait till the listener is created - listener := new(v1alpha1.AutoscalingListener) - Eventually( - func() error { - return k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: autoscalingRunnerSet.Namespace}, listener) - }, - autoscalingRunnerSetTestTimeout, - autoscalingRunnerSetTestInterval, - ).Should(Succeed(), "Listener should be created") - - // Wait till the ephemeral runner set is created - Eventually( - func() (int, error) { - runnerSetList := new(v1alpha1.EphemeralRunnerSetList) - err := k8sClient.List(ctx, runnerSetList, client.InNamespace(autoscalingRunnerSet.Namespace)) - if err != nil { - return 0, err - } - - return len(runnerSetList.Items), nil - }, - autoscalingRunnerSetTestTimeout, - autoscalingRunnerSetTestInterval, - ).Should(BeEquivalentTo(1), "Only one EphemeralRunnerSet should be created") - - runnerSetList := new(v1alpha1.EphemeralRunnerSetList) - err := k8sClient.List(ctx, runnerSetList, client.InNamespace(autoscalingRunnerSet.Namespace)) - Expect(err).NotTo(HaveOccurred(), "failed to list EphemeralRunnerSet") - - // Emulate running and pending jobs - runnerSet := runnerSetList.Items[0] - activeRunnerSet := runnerSet.DeepCopy() - activeRunnerSet.Status.CurrentReplicas = 6 - activeRunnerSet.Status.FailedEphemeralRunners = 1 - activeRunnerSet.Status.RunningEphemeralRunners = 2 - activeRunnerSet.Status.PendingEphemeralRunners = 3 - - desiredStatus := v1alpha1.AutoscalingRunnerSetStatus{ - CurrentRunners: activeRunnerSet.Status.CurrentReplicas, - Phase: v1alpha1.AutoscalingRunnerSetPhaseRunning, - PendingEphemeralRunners: activeRunnerSet.Status.PendingEphemeralRunners, - RunningEphemeralRunners: activeRunnerSet.Status.RunningEphemeralRunners, - FailedEphemeralRunners: activeRunnerSet.Status.FailedEphemeralRunners, - } - - err = k8sClient.Status().Patch(ctx, activeRunnerSet, client.MergeFrom(&runnerSet)) - Expect(err).NotTo(HaveOccurred(), "Failed to patch runner set status") - - Eventually( - func() (v1alpha1.AutoscalingRunnerSetStatus, error) { - updated := new(v1alpha1.AutoscalingRunnerSet) - err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, updated) - if err != nil { - return v1alpha1.AutoscalingRunnerSetStatus{}, fmt.Errorf("failed to get AutoScalingRunnerSet: %w", err) - } - return updated.Status, nil - }, - autoscalingRunnerSetTestTimeout, - autoscalingRunnerSetTestInterval, - ).Should(BeEquivalentTo(desiredStatus), "AutoScalingRunnerSet status should be updated") - - // Patch the AutoScalingRunnerSet image which should trigger - // the recreation of the Listener and EphemeralRunnerSet - patched := autoscalingRunnerSet.DeepCopy() - if patched.Annotations == nil { - patched.Annotations = make(map[string]string) - } - patched.Annotations[annotationKeyIntegrityHash] = "testgroup2" - patched.Spec.Template.Spec = corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "runner", - Image: "ghcr.io/actions/abcd:1.1.1", - }, - }, - } - err = k8sClient.Patch(ctx, patched, client.MergeFrom(autoscalingRunnerSet)) - Expect(err).NotTo(HaveOccurred(), "failed to patch AutoScalingRunnerSet") - autoscalingRunnerSet = patched.DeepCopy() - - // The EphemeralRunnerSet should not be recreated - Consistently( - func() (string, error) { - runnerSetList := new(v1alpha1.EphemeralRunnerSetList) - err := k8sClient.List(ctx, runnerSetList, client.InNamespace(autoscalingRunnerSet.Namespace)) - Expect(err).NotTo(HaveOccurred(), "failed to fetch AutoScalingRunnerSet") - return runnerSetList.Items[0].Name, nil - }, - autoscalingRunnerSetTestTimeout, - autoscalingRunnerSetTestInterval, - ).Should(Equal(activeRunnerSet.Name), "The EphemeralRunnerSet should not be recreated") - - // The listener should not be recreated - Consistently( - func() error { - return k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: autoscalingRunnerSet.Namespace}, listener) - }, - autoscalingRunnerSetTestTimeout, - autoscalingRunnerSetTestInterval, - ).ShouldNot(Succeed(), "Listener should not be recreated") - }) - }) - It("Should update Status on EphemeralRunnerSet status Update", func() { ars := new(v1alpha1.AutoscalingRunnerSet) Eventually( @@ -978,17 +873,10 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { runnerSet := runnerSetList.Items[0] statusUpdate := runnerSet.DeepCopy() - statusUpdate.Status.CurrentReplicas = 6 - statusUpdate.Status.FailedEphemeralRunners = 1 - statusUpdate.Status.RunningEphemeralRunners = 2 - statusUpdate.Status.PendingEphemeralRunners = 3 + statusUpdate.Status.Phase = v1alpha1.EphemeralRunnerSetPhaseRunning desiredStatus := v1alpha1.AutoscalingRunnerSetStatus{ - CurrentRunners: statusUpdate.Status.CurrentReplicas, - Phase: v1alpha1.AutoscalingRunnerSetPhaseRunning, - PendingEphemeralRunners: statusUpdate.Status.PendingEphemeralRunners, - RunningEphemeralRunners: statusUpdate.Status.RunningEphemeralRunners, - FailedEphemeralRunners: statusUpdate.Status.FailedEphemeralRunners, + Phase: v1alpha1.AutoscalingRunnerSetPhaseRunning, } err := k8sClient.Status().Patch(ctx, statusUpdate, client.MergeFrom(&runnerSet)) diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller.go b/controllers/actions.github.com/ephemeralrunnerset_controller.go index 54aa85ae90..919a46419f 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller.go @@ -246,7 +246,6 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R func (r *EphemeralRunnerSetReconciler) updateStatus(ctx context.Context, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, state *ephemeralRunnersByState, log logr.Logger) error { original := ephemeralRunnerSet.DeepCopy() - total := state.scaleTotal() var phase v1alpha1.EphemeralRunnerSetPhase switch { case len(state.outdated) > 0: @@ -257,11 +256,7 @@ func (r *EphemeralRunnerSetReconciler) updateStatus(ctx context.Context, ephemer phase = ephemeralRunnerSet.Status.Phase } desiredStatus := v1alpha1.EphemeralRunnerSetStatus{ - CurrentReplicas: total, - Phase: phase, - PendingEphemeralRunners: len(state.pending), - RunningEphemeralRunners: len(state.running), - FailedEphemeralRunners: len(state.failed), + Phase: phase, } // Update the status if needed. diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go index 3533e0dd4c..c68c569c31 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go @@ -222,20 +222,20 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { ephemeralRunnerSetTestInterval, ).Should(BeEquivalentTo(0), "No EphemeralRunner should be created") - // Check if the status stay 0 + // Check if the status is initialized Consistently( - func() (int, error) { + func() (v1alpha1.EphemeralRunnerSetPhase, error) { runnerSet := new(v1alpha1.EphemeralRunnerSet) err := k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunnerSet.Name, Namespace: ephemeralRunnerSet.Namespace}, runnerSet) if err != nil { - return -1, err + return "", err } - return int(runnerSet.Status.CurrentReplicas), nil + return runnerSet.Status.Phase, nil }, ephemeralRunnerSetTestTimeout, ephemeralRunnerSetTestInterval, - ).Should(BeEquivalentTo(0), "EphemeralRunnerSet status should be 0") + ).Should(BeEquivalentTo(v1alpha1.EphemeralRunnerSetPhaseRunning), "EphemeralRunnerSet status should be running") // Scaling up the EphemeralRunnerSet updated := created.DeepCopy() @@ -1258,11 +1258,7 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { ).Should(BeTrue(), "Failed to eventually update to one pending, one running and one failed") desiredStatus := v1alpha1.EphemeralRunnerSetStatus{ - Phase: v1alpha1.EphemeralRunnerSetPhaseRunning, - CurrentReplicas: 3, - PendingEphemeralRunners: 1, - RunningEphemeralRunners: 1, - FailedEphemeralRunners: 1, + Phase: v1alpha1.EphemeralRunnerSetPhaseRunning, } Eventually( func() (v1alpha1.EphemeralRunnerSetStatus, error) { @@ -1301,11 +1297,7 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { ).Should(BeEquivalentTo(1), "Failed to eventually scale down") desiredStatus = v1alpha1.EphemeralRunnerSetStatus{ - CurrentReplicas: 1, - PendingEphemeralRunners: 0, - RunningEphemeralRunners: 0, - FailedEphemeralRunners: 1, - Phase: v1alpha1.EphemeralRunnerSetPhaseRunning, + Phase: v1alpha1.EphemeralRunnerSetPhaseRunning, } Eventually( @@ -1325,11 +1317,7 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { Expect(err).To(BeNil(), "Failed to delete failed ephemeral runner") desiredStatus = v1alpha1.EphemeralRunnerSetStatus{ - CurrentReplicas: 0, - PendingEphemeralRunners: 0, - RunningEphemeralRunners: 0, - FailedEphemeralRunners: 0, - Phase: v1alpha1.EphemeralRunnerSetPhaseRunning, + Phase: v1alpha1.EphemeralRunnerSetPhaseRunning, } Eventually( func() (v1alpha1.EphemeralRunnerSetStatus, error) { From 0243eb13e0263a056e74186552f9969df2f212dd Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Mon, 13 Jul 2026 14:46:10 +0200 Subject: [PATCH 18/31] revert optional tags --- .../v1alpha1/ephemeralrunnerset_types.go | 10 ++++++++-- .../crds/actions.github.com_ephemeralrunnersets.yaml | 2 -- .../crds/actions.github.com_ephemeralrunnersets.yaml | 2 -- .../bases/actions.github.com_ephemeralrunnersets.yaml | 2 -- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apis/actions.github.com/v1alpha1/ephemeralrunnerset_types.go b/apis/actions.github.com/v1alpha1/ephemeralrunnerset_types.go index 5e8f12d19f..641ec02f88 100644 --- a/apis/actions.github.com/v1alpha1/ephemeralrunnerset_types.go +++ b/apis/actions.github.com/v1alpha1/ephemeralrunnerset_types.go @@ -23,10 +23,13 @@ import ( // EphemeralRunnerSetSpec defines the desired state of EphemeralRunnerSet type EphemeralRunnerSetSpec struct { // Replicas is the number of desired EphemeralRunner resources in the k8s namespace. + // +optional Replicas int `json:"replicas,omitempty"` // PatchID is the unique identifier for the patch issued by the listener app + // +optional PatchID int `json:"patchID"` // EphemeralRunnerSpec is the spec of the ephemeral runner + // +optional EphemeralRunnerSpec EphemeralRunnerSpec `json:"ephemeralRunnerSpec,omitempty"` // EphemeralRunnerMetadata is the metadata to be applied to all ephemeral runners created by this set. // If the EphemeralRunnerMetadata is updated, the update applies to new ephemeral runners created after the update, @@ -58,10 +61,13 @@ const ( // EphemeralRunnerSet is the Schema for the ephemeralrunnersets API type EphemeralRunnerSet struct { - metav1.TypeMeta `json:",inline"` + metav1.TypeMeta `json:",inline"` + // +optional metav1.ObjectMeta `json:"metadata,omitempty"` - Spec EphemeralRunnerSetSpec `json:"spec,omitempty"` + // +optional + Spec EphemeralRunnerSetSpec `json:"spec,omitempty"` + // +optional Status EphemeralRunnerSetStatus `json:"status,omitempty"` } diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml index 2d17def801..a6f9758bfa 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml @@ -8291,8 +8291,6 @@ spec: replicas: description: Replicas is the number of desired EphemeralRunner resources in the k8s namespace. type: integer - required: - - patchID type: object status: description: EphemeralRunnerSetStatus defines the observed state of EphemeralRunnerSet diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml index 2d17def801..a6f9758bfa 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml @@ -8291,8 +8291,6 @@ spec: replicas: description: Replicas is the number of desired EphemeralRunner resources in the k8s namespace. type: integer - required: - - patchID type: object status: description: EphemeralRunnerSetStatus defines the observed state of EphemeralRunnerSet diff --git a/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml b/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml index 2d17def801..a6f9758bfa 100644 --- a/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml +++ b/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml @@ -8291,8 +8291,6 @@ spec: replicas: description: Replicas is the number of desired EphemeralRunner resources in the k8s namespace. type: integer - required: - - patchID type: object status: description: EphemeralRunnerSetStatus defines the observed state of EphemeralRunnerSet From 9ad6faacb34534f49fa72ab6de2ff2e4397f8be8 Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Mon, 6 Jul 2026 14:37:20 +0200 Subject: [PATCH 19/31] Use metrics to display runner statuses instead of status field for EphemeralRunnerSet and AutoscalingRunnerSet --- .../v1alpha1/autoscalingrunnerset_types.go | 5 ----- .../actions.github.com_autoscalingrunnersets.yaml | 15 --------------- .../actions.github.com_autoscalingrunnersets.yaml | 15 --------------- .../actions.github.com_autoscalingrunnersets.yaml | 15 --------------- .../autoscalingrunnerset_controller_test.go | 1 - 5 files changed, 51 deletions(-) diff --git a/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go b/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go index dd1bce7fa1..465df33026 100644 --- a/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go +++ b/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go @@ -36,12 +36,7 @@ import ( // +kubebuilder:subresource:status // +kubebuilder:printcolumn:JSONPath=".spec.minRunners",name=Minimum Runners,type=integer // +kubebuilder:printcolumn:JSONPath=".spec.maxRunners",name=Maximum Runners,type=integer -// +kubebuilder:printcolumn:JSONPath=".status.currentRunners",name=Current Runners,type=integer // +kubebuilder:printcolumn:JSONPath=".status.phase",name=Phase,type=string -// +kubebuilder:printcolumn:JSONPath=".status.pendingEphemeralRunners",name=Pending Runners,type=integer -// +kubebuilder:printcolumn:JSONPath=".status.runningEphemeralRunners",name=Running Runners,type=integer -// +kubebuilder:printcolumn:JSONPath=".status.finishedEphemeralRunners",name=Finished Runners,type=integer -// +kubebuilder:printcolumn:JSONPath=".status.deletingEphemeralRunners",name=Deleting Runners,type=integer // AutoscalingRunnerSet is the Schema for the autoscalingrunnersets API type AutoscalingRunnerSet struct { diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml index a2dfd46d9f..c05ad9fc1b 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml @@ -21,24 +21,9 @@ spec: - jsonPath: .spec.maxRunners name: Maximum Runners type: integer - - jsonPath: .status.currentRunners - name: Current Runners - type: integer - jsonPath: .status.phase name: Phase type: string - - jsonPath: .status.pendingEphemeralRunners - name: Pending Runners - type: integer - - jsonPath: .status.runningEphemeralRunners - name: Running Runners - type: integer - - jsonPath: .status.finishedEphemeralRunners - name: Finished Runners - type: integer - - jsonPath: .status.deletingEphemeralRunners - name: Deleting Runners - type: integer name: v1alpha1 schema: openAPIV3Schema: diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml index a2dfd46d9f..c05ad9fc1b 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml @@ -21,24 +21,9 @@ spec: - jsonPath: .spec.maxRunners name: Maximum Runners type: integer - - jsonPath: .status.currentRunners - name: Current Runners - type: integer - jsonPath: .status.phase name: Phase type: string - - jsonPath: .status.pendingEphemeralRunners - name: Pending Runners - type: integer - - jsonPath: .status.runningEphemeralRunners - name: Running Runners - type: integer - - jsonPath: .status.finishedEphemeralRunners - name: Finished Runners - type: integer - - jsonPath: .status.deletingEphemeralRunners - name: Deleting Runners - type: integer name: v1alpha1 schema: openAPIV3Schema: diff --git a/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml b/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml index a2dfd46d9f..c05ad9fc1b 100644 --- a/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml +++ b/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml @@ -21,24 +21,9 @@ spec: - jsonPath: .spec.maxRunners name: Maximum Runners type: integer - - jsonPath: .status.currentRunners - name: Current Runners - type: integer - jsonPath: .status.phase name: Phase type: string - - jsonPath: .status.pendingEphemeralRunners - name: Pending Runners - type: integer - - jsonPath: .status.runningEphemeralRunners - name: Running Runners - type: integer - - jsonPath: .status.finishedEphemeralRunners - name: Finished Runners - type: integer - - jsonPath: .status.deletingEphemeralRunners - name: Deleting Runners - type: integer name: v1alpha1 schema: openAPIV3Schema: diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go index 11a03dc482..796dcd6ecf 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go @@ -836,7 +836,6 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { ).Should(BeEquivalentTo("testgroup2"), "AutoScalingRunnerSet should have the runner group in its annotation") }) }) - It("Should update Status on EphemeralRunnerSet status Update", func() { ars := new(v1alpha1.AutoscalingRunnerSet) Eventually( From 76027003c7cb011d0f8ed4269d4c6e3ab01f73a5 Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Mon, 13 Jul 2026 17:27:51 +0200 Subject: [PATCH 20/31] Include resource cache for desired resources --- .../v1alpha1/autoscalingrunnerset_types.go | 5 + ...ions.github.com_autoscalingrunnersets.yaml | 15 + ...ions.github.com_autoscalingrunnersets.yaml | 15 + ...ions.github.com_autoscalingrunnersets.yaml | 15 + .../autoscalinglistener_controller.go | 1 + .../autoscalinglistener_controller_test.go | 5 + .../autoscalingrunnerset_controller.go | 1 + .../autoscalingrunnerset_controller_test.go | 9 + .../ephemeralrunner_controller.go | 2 +- .../ephemeralrunner_controller_test.go | 6 + .../ephemeralrunnerset_controller.go | 1 + .../ephemeralrunnerset_controller_test.go | 4 + .../actions.github.com/resourcebuilder.go | 111 +++++- .../resourcebuilder_test.go | 1 + .../actions.github.com/resourcecache.go | 263 +++++++++++++ .../actions.github.com/resourcecache_test.go | 351 ++++++++++++++++++ main.go | 2 + 17 files changed, 805 insertions(+), 2 deletions(-) create mode 100644 controllers/actions.github.com/resourcecache.go create mode 100644 controllers/actions.github.com/resourcecache_test.go diff --git a/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go b/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go index 465df33026..dd1bce7fa1 100644 --- a/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go +++ b/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go @@ -36,7 +36,12 @@ import ( // +kubebuilder:subresource:status // +kubebuilder:printcolumn:JSONPath=".spec.minRunners",name=Minimum Runners,type=integer // +kubebuilder:printcolumn:JSONPath=".spec.maxRunners",name=Maximum Runners,type=integer +// +kubebuilder:printcolumn:JSONPath=".status.currentRunners",name=Current Runners,type=integer // +kubebuilder:printcolumn:JSONPath=".status.phase",name=Phase,type=string +// +kubebuilder:printcolumn:JSONPath=".status.pendingEphemeralRunners",name=Pending Runners,type=integer +// +kubebuilder:printcolumn:JSONPath=".status.runningEphemeralRunners",name=Running Runners,type=integer +// +kubebuilder:printcolumn:JSONPath=".status.finishedEphemeralRunners",name=Finished Runners,type=integer +// +kubebuilder:printcolumn:JSONPath=".status.deletingEphemeralRunners",name=Deleting Runners,type=integer // AutoscalingRunnerSet is the Schema for the autoscalingrunnersets API type AutoscalingRunnerSet struct { diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml index c05ad9fc1b..a2dfd46d9f 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml @@ -21,9 +21,24 @@ spec: - jsonPath: .spec.maxRunners name: Maximum Runners type: integer + - jsonPath: .status.currentRunners + name: Current Runners + type: integer - jsonPath: .status.phase name: Phase type: string + - jsonPath: .status.pendingEphemeralRunners + name: Pending Runners + type: integer + - jsonPath: .status.runningEphemeralRunners + name: Running Runners + type: integer + - jsonPath: .status.finishedEphemeralRunners + name: Finished Runners + type: integer + - jsonPath: .status.deletingEphemeralRunners + name: Deleting Runners + type: integer name: v1alpha1 schema: openAPIV3Schema: diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml index c05ad9fc1b..a2dfd46d9f 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml @@ -21,9 +21,24 @@ spec: - jsonPath: .spec.maxRunners name: Maximum Runners type: integer + - jsonPath: .status.currentRunners + name: Current Runners + type: integer - jsonPath: .status.phase name: Phase type: string + - jsonPath: .status.pendingEphemeralRunners + name: Pending Runners + type: integer + - jsonPath: .status.runningEphemeralRunners + name: Running Runners + type: integer + - jsonPath: .status.finishedEphemeralRunners + name: Finished Runners + type: integer + - jsonPath: .status.deletingEphemeralRunners + name: Deleting Runners + type: integer name: v1alpha1 schema: openAPIV3Schema: diff --git a/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml b/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml index c05ad9fc1b..a2dfd46d9f 100644 --- a/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml +++ b/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml @@ -21,9 +21,24 @@ spec: - jsonPath: .spec.maxRunners name: Maximum Runners type: integer + - jsonPath: .status.currentRunners + name: Current Runners + type: integer - jsonPath: .status.phase name: Phase type: string + - jsonPath: .status.pendingEphemeralRunners + name: Pending Runners + type: integer + - jsonPath: .status.runningEphemeralRunners + name: Running Runners + type: integer + - jsonPath: .status.finishedEphemeralRunners + name: Finished Runners + type: integer + - jsonPath: .status.deletingEphemeralRunners + name: Deleting Runners + type: integer name: v1alpha1 schema: openAPIV3Schema: diff --git a/controllers/actions.github.com/autoscalinglistener_controller.go b/controllers/actions.github.com/autoscalinglistener_controller.go index c0aa81114c..af19a6f2fe 100644 --- a/controllers/actions.github.com/autoscalinglistener_controller.go +++ b/controllers/actions.github.com/autoscalinglistener_controller.go @@ -105,6 +105,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. } log.Info("Successfully removed finalizer after cleanup") + r.ResourceCache.Delete(&autoscalingListener) return ctrl.Result{}, nil } diff --git a/controllers/actions.github.com/autoscalinglistener_controller_test.go b/controllers/actions.github.com/autoscalinglistener_controller_test.go index d48d613e6c..83a499c4f9 100644 --- a/controllers/actions.github.com/autoscalinglistener_controller_test.go +++ b/controllers/actions.github.com/autoscalinglistener_controller_test.go @@ -50,6 +50,7 @@ var _ = Describe("Test AutoScalingListener controller", func() { ) rb := ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretResolver, } @@ -593,6 +594,7 @@ var _ = Describe("Test AutoScalingListener customization", func() { secretResolver := secretresolver.New(mgr.GetClient(), scalefake.NewMultiClient()) rb := ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretResolver, } @@ -922,6 +924,7 @@ var _ = Describe("Test AutoScalingListener controller with proxy", func() { secretResolver := secretresolver.New(mgr.GetClient(), scalefake.NewMultiClient()) rb := ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretResolver, } @@ -1127,6 +1130,7 @@ var _ = Describe("Test AutoScalingListener controller with template modification secretResolver := secretresolver.New(mgr.GetClient(), scalefake.NewMultiClient()) rb := ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretResolver, } @@ -1232,6 +1236,7 @@ var _ = Describe("Test GitHub Server TLS configuration", func() { secretResolver := secretresolver.New(mgr.GetClient(), scalefake.NewMultiClient()) rb := ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretResolver, } diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller.go b/controllers/actions.github.com/autoscalingrunnerset_controller.go index e48f75ff15..9d56ffc711 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller.go @@ -108,6 +108,7 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl } log.Info("Successfully removed finalizer after cleanup") + r.ResourceCache.Delete(&autoscalingRunnerSet) return ctrl.Result{}, nil } diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go index 796dcd6ecf..82177acb4c 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go @@ -73,6 +73,7 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { ControllerNamespace: autoscalingNS.Name, DefaultRunnerScaleSetListenerImage: "ghcr.io/actions/arc", ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretresolver.New(mgr.GetClient(), scalefake.NewMultiClient( scalefake.WithClient( scalefake.NewClient( @@ -836,6 +837,7 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { ).Should(BeEquivalentTo("testgroup2"), "AutoScalingRunnerSet should have the runner group in its annotation") }) }) + It("Should update Status on EphemeralRunnerSet status Update", func() { ars := new(v1alpha1.AutoscalingRunnerSet) Eventually( @@ -960,6 +962,7 @@ var _ = Describe("Test AutoScalingController updates", Ordered, func() { ControllerNamespace: autoscalingNS.Name, DefaultRunnerScaleSetListenerImage: "ghcr.io/actions/arc", ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretresolver.New(mgr.GetClient(), multiClient), }, } @@ -1077,6 +1080,7 @@ var _ = Describe("Test AutoscalingController creation failures", Ordered, func() ControllerNamespace: autoscalingNS.Name, DefaultRunnerScaleSetListenerImage: "ghcr.io/actions/arc", ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretresolver.New(mgr.GetClient(), scalefake.NewMultiClient()), }, } @@ -1204,6 +1208,7 @@ var _ = Describe("Test client optional configuration", Ordered, func() { ControllerNamespace: autoscalingNS.Name, DefaultRunnerScaleSetListenerImage: "ghcr.io/actions/arc", ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretresolver.New(mgr.GetClient(), multiclient.NewScaleset()), }, } @@ -1399,6 +1404,7 @@ var _ = Describe("Test client optional configuration", Ordered, func() { ControllerNamespace: autoscalingNS.Name, DefaultRunnerScaleSetListenerImage: "ghcr.io/actions/arc", ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretresolver.New(mgr.GetClient(), scalefake.NewMultiClient( scalefake.WithClient( scalefake.NewClient( @@ -1646,6 +1652,7 @@ var _ = Describe("Test external permissions cleanup", Ordered, func() { ControllerNamespace: autoscalingNS.Name, DefaultRunnerScaleSetListenerImage: "ghcr.io/actions/arc", ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretresolver.New(mgr.GetClient(), scalefake.NewMultiClient()), }, } @@ -1806,6 +1813,7 @@ var _ = Describe("Test external permissions cleanup", Ordered, func() { ControllerNamespace: autoscalingNS.Name, DefaultRunnerScaleSetListenerImage: "ghcr.io/actions/arc", ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretresolver.New(mgr.GetClient(), scalefake.NewMultiClient()), }, } @@ -2016,6 +2024,7 @@ var _ = Describe("Test resource version and build version mismatch", func() { ControllerNamespace: autoscalingNS.Name, DefaultRunnerScaleSetListenerImage: "ghcr.io/actions/arc", ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretresolver.New(mgr.GetClient(), scalefake.NewMultiClient()), }, } diff --git a/controllers/actions.github.com/ephemeralrunner_controller.go b/controllers/actions.github.com/ephemeralrunner_controller.go index e78ede68ff..256ed311c4 100644 --- a/controllers/actions.github.com/ephemeralrunner_controller.go +++ b/controllers/actions.github.com/ephemeralrunner_controller.go @@ -151,7 +151,7 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ } } - log.Info("Successfully removed finalizer after cleanup") + r.ResourceCache.Delete(&ephemeralRunner) return ctrl.Result{}, nil } diff --git a/controllers/actions.github.com/ephemeralrunner_controller_test.go b/controllers/actions.github.com/ephemeralrunner_controller_test.go index 80c27134a7..ddd666bb24 100644 --- a/controllers/actions.github.com/ephemeralrunner_controller_test.go +++ b/controllers/actions.github.com/ephemeralrunner_controller_test.go @@ -111,6 +111,7 @@ var _ = Describe("EphemeralRunner", func() { Scheme: mgr.GetScheme(), Log: logf.Log, ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretresolver.New(mgr.GetClient(), scalefake.NewMultiClient( scalefake.WithClient( scalefake.NewClient( @@ -1216,6 +1217,7 @@ var _ = Describe("EphemeralRunner", func() { Scheme: mgr.GetScheme(), Log: logf.Log, ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretresolver.New( mgr.GetClient(), scalefake.NewMultiClient( @@ -1302,6 +1304,7 @@ var _ = Describe("EphemeralRunner", func() { Scheme: mgr.GetScheme(), Log: logf.Log, ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretresolver.New(mgr.GetClient(), scalefake.NewMultiClient( scalefake.WithClient( scalefake.NewClient( @@ -1326,6 +1329,7 @@ var _ = Describe("EphemeralRunner", func() { It("uses an actions client with proxy transport", func() { // Use an actual client controller.ResourceBuilder = ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretresolver.New( mgr.GetClient(), multiclient.NewScaleset(), @@ -1485,6 +1489,7 @@ var _ = Describe("EphemeralRunner", func() { Scheme: mgr.GetScheme(), Log: logf.Log, ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretresolver.New(mgr.GetClient(), scalefake.NewMultiClient()), }, } @@ -1519,6 +1524,7 @@ var _ = Describe("EphemeralRunner", func() { // Use an actual client controller.ResourceBuilder = ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretresolver.New( mgr.GetClient(), multiclient.NewScaleset(), diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller.go b/controllers/actions.github.com/ephemeralrunnerset_controller.go index 919a46419f..c4f6d8c025 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller.go @@ -117,6 +117,7 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R } log.Info("Successfully removed finalizer after cleanup") + r.ResourceCache.Delete(&ephemeralRunnerSet) return ctrl.Result{}, nil } diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go index c68c569c31..2ee42ac274 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go @@ -144,6 +144,7 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { Scheme: mgr.GetScheme(), Log: logf.Log, ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretresolver.New(mgr.GetClient(), fake.NewMultiClient( fake.WithClient( fake.NewClient( @@ -1363,6 +1364,7 @@ var _ = Describe("EphemeralRunner phase metrics", func() { Log: logf.Log, PublishMetrics: true, ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretresolver.New(k8sClient, fake.NewMultiClient( fake.WithClient( fake.NewClient( @@ -1494,6 +1496,7 @@ var _ = Describe("Test EphemeralRunnerSet controller with proxy settings", func( Scheme: mgr.GetScheme(), Log: logf.Log, ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretresolver.New(mgr.GetClient(), multiclient.NewScaleset()), }, } @@ -1812,6 +1815,7 @@ var _ = Describe("Test EphemeralRunnerSet controller with custom root CA", func( Scheme: mgr.GetScheme(), Log: logf.Log, ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), SecretResolver: secretresolver.New(mgr.GetClient(), multiclient.NewScaleset()), }, } diff --git a/controllers/actions.github.com/resourcebuilder.go b/controllers/actions.github.com/resourcebuilder.go index fd456b56f1..8d6ae26cb6 100644 --- a/controllers/actions.github.com/resourcebuilder.go +++ b/controllers/actions.github.com/resourcebuilder.go @@ -95,7 +95,8 @@ type SecretResolver interface { type ResourceBuilder struct { ExcludeLabelPropagationPrefixes []string SecretResolver - Scheme *runtime.Scheme + Scheme *runtime.Scheme + ResourceCache *ResourceCache } func (b *ResourceBuilder) setSchemeIfUnset(scheme *runtime.Scheme) { @@ -121,6 +122,27 @@ func (b *ResourceBuilder) newAutoscalingListener(autoscalingRunnerSet *v1alpha1. return nil, err } + cacheKeyObject := &v1alpha1.AutoscalingListener{ + ObjectMeta: metav1.ObjectMeta{ + Name: scaleSetListenerName(autoscalingRunnerSet), + Namespace: namespace, + }, + } + inputDependency := resourceCacheInputObject("autoscaling-listener-inputs", struct { + Namespace string + Image string + ImagePullSecrets []corev1.LocalObjectReference + }{ + Namespace: namespace, + Image: image, + ImagePullSecrets: imagePullSecrets, + }) + if b.ResourceCache != nil { + if cached, ok := b.ResourceCache.autoscalingListener.Get(autoscalingRunnerSet, cacheKeyObject, ephemeralRunnerSet, inputDependency); ok { + return cached, nil + } + } + effectiveMinRunners := 0 effectiveMaxRunners := math.MaxInt32 if autoscalingRunnerSet.Spec.MaxRunners != nil { @@ -182,10 +204,22 @@ func (b *ResourceBuilder) newAutoscalingListener(autoscalingRunnerSet *v1alpha1. }, Spec: spec, } + if b.ResourceCache != nil { + b.ResourceCache.autoscalingListener.Upsert(autoscalingRunnerSet, autoscalingListener, ephemeralRunnerSet, inputDependency) + } return autoscalingListener, nil } +func resourceCacheInputObject(name string, value any) client.Object { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + ResourceVersion: hash.ComputeTemplateHash(value), + }, + } +} + type listenerMetricsServerConfig struct { addr string endpoint string @@ -307,6 +341,18 @@ func (b *ResourceBuilder) newScaleSetListenerPod( roleBinding *rbacv1.RoleBinding, metricsConfig *listenerMetricsServerConfig, ) (*corev1.Pod, error) { + cacheKeyObject := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: autoscalingListener.Name, + Namespace: autoscalingListener.Namespace, + }, + } + if b.ResourceCache != nil { + if cached, ok := b.ResourceCache.listenerPod.Get(autoscalingListener, cacheKeyObject, podConfig, serviceAccount, role, roleBinding); ok { + return cached, nil + } + } + envs := []corev1.EnvVar{ { Name: "LISTENER_CONFIG_PATH", @@ -443,6 +489,9 @@ func (b *ResourceBuilder) newScaleSetListenerPod( if autoscalingListener.Spec.Template != nil { mergeListenerPodWithTemplate(newRunnerScaleSetListenerPod, autoscalingListener.Spec.Template) } + if b.ResourceCache != nil { + b.ResourceCache.listenerPod.Upsert(autoscalingListener, newRunnerScaleSetListenerPod, podConfig, serviceAccount, role, roleBinding) + } return newRunnerScaleSetListenerPod, nil } @@ -597,6 +646,18 @@ func mergeListenerContainer(base, from *corev1.Container) { } func (b *ResourceBuilder) newScaleSetListenerServiceAccount(autoscalingListener *v1alpha1.AutoscalingListener) (*corev1.ServiceAccount, error) { + cacheKeyObject := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: autoscalingListener.Name, + Namespace: autoscalingListener.Namespace, + }, + } + if b.ResourceCache != nil { + if cached, ok := b.ResourceCache.listenerServiceAccount.Get(autoscalingListener, cacheKeyObject); ok { + return cached, nil + } + } + base := &corev1.ServiceAccount{ ObjectMeta: metav1.ObjectMeta{ Name: autoscalingListener.Name, @@ -619,6 +680,9 @@ func (b *ResourceBuilder) newScaleSetListenerServiceAccount(autoscalingListener if err := b.setControllerReference(autoscalingListener, base); err != nil { return nil, fmt.Errorf("failed to set controller reference for listener service account: %w", err) } + if b.ResourceCache != nil { + b.ResourceCache.listenerServiceAccount.Upsert(autoscalingListener, base) + } return base, nil } @@ -640,6 +704,18 @@ func scaleSetListenerServiceAccountIntegrityHash(sa *corev1.ServiceAccount) stri } func (b *ResourceBuilder) newScaleSetListenerRole(autoscalingListener *v1alpha1.AutoscalingListener) *rbacv1.Role { + cacheKeyObject := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: autoscalingListener.Name, + Namespace: autoscalingListener.Spec.AutoscalingRunnerSetNamespace, + }, + } + if b.ResourceCache != nil { + if cached, ok := b.ResourceCache.listenerRole.Get(autoscalingListener, cacheKeyObject); ok { + return cached + } + } + labels := b.filterAndMergeLabels(autoscalingListener.Labels, map[string]string{ LabelKeyGitHubScaleSetNamespace: autoscalingListener.Spec.AutoscalingRunnerSetNamespace, LabelKeyGitHubScaleSetName: autoscalingListener.Spec.AutoscalingRunnerSetName, @@ -664,6 +740,9 @@ func (b *ResourceBuilder) newScaleSetListenerRole(autoscalingListener *v1alpha1. } newRole.Annotations[annotationKeyIntegrityHash] = scaleSetRoleIntegrityHash(newRole) + if b.ResourceCache != nil { + b.ResourceCache.listenerRole.Upsert(autoscalingListener, newRole) + } return newRole } @@ -681,6 +760,18 @@ func scaleSetRoleIntegrityHash(role *rbacv1.Role) string { } func (b *ResourceBuilder) newScaleSetListenerRoleBinding(autoscalingListener *v1alpha1.AutoscalingListener, listenerRole *rbacv1.Role, serviceAccount *corev1.ServiceAccount) *rbacv1.RoleBinding { + cacheKeyObject := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: autoscalingListener.Name, + Namespace: autoscalingListener.Spec.AutoscalingRunnerSetNamespace, + }, + } + if b.ResourceCache != nil { + if cached, ok := b.ResourceCache.listenerRoleBinding.Get(autoscalingListener, cacheKeyObject, listenerRole, serviceAccount); ok { + return cached + } + } + roleRef := rbacv1.RoleRef{ Kind: "Role", Name: listenerRole.Name, @@ -719,6 +810,9 @@ func (b *ResourceBuilder) newScaleSetListenerRoleBinding(autoscalingListener *v1 } newRoleBinding.Annotations[annotationKeyIntegrityHash] = scaleSetListenerRoleBindingIntegrityHash(newRoleBinding) + if b.ResourceCache != nil { + b.ResourceCache.listenerRoleBinding.Upsert(autoscalingListener, newRoleBinding, listenerRole, serviceAccount) + } return newRoleBinding } @@ -743,6 +837,18 @@ func (b *ResourceBuilder) newEphemeralRunnerSet(autoscalingRunnerSet *v1alpha1.A return nil, err } + cacheKeyObject := &v1alpha1.EphemeralRunnerSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: autoscalingRunnerSet.Name, + Namespace: autoscalingRunnerSet.Namespace, + }, + } + if b.ResourceCache != nil { + if cached, ok := b.ResourceCache.ephemeralRunnerSet.Get(autoscalingRunnerSet, cacheKeyObject); ok { + return cached, nil + } + } + spec := v1alpha1.EphemeralRunnerSetSpec{ Replicas: 0, EphemeralRunnerSpec: v1alpha1.EphemeralRunnerSpec{ @@ -796,6 +902,9 @@ func (b *ResourceBuilder) newEphemeralRunnerSet(autoscalingRunnerSet *v1alpha1.A if err := b.setControllerReference(autoscalingRunnerSet, newEphemeralRunnerSet); err != nil { return nil, fmt.Errorf("failed to set controller reference for ephemeral runner set: %w", err) } + if b.ResourceCache != nil { + b.ResourceCache.ephemeralRunnerSet.Upsert(autoscalingRunnerSet, newEphemeralRunnerSet) + } return newEphemeralRunnerSet, nil } diff --git a/controllers/actions.github.com/resourcebuilder_test.go b/controllers/actions.github.com/resourcebuilder_test.go index d08851173a..4324401e11 100644 --- a/controllers/actions.github.com/resourcebuilder_test.go +++ b/controllers/actions.github.com/resourcebuilder_test.go @@ -171,6 +171,7 @@ func TestMetadataPropagation(t *testing.T) { ephemeralRunner, err := b.newEphemeralRunner(ephemeralRunnerSet) require.NoError(t, err) + assert.ElementsMatch(t, []string{ephemeralRunnerFinalizerName, ephemeralRunnerActionsFinalizerName}, ephemeralRunner.Finalizers) for _, key := range commonLabelKeys { if key == LabelKeyKubernetesComponent { diff --git a/controllers/actions.github.com/resourcecache.go b/controllers/actions.github.com/resourcecache.go new file mode 100644 index 0000000000..1f631be01b --- /dev/null +++ b/controllers/actions.github.com/resourcecache.go @@ -0,0 +1,263 @@ +package actionsgithubcom + +import ( + "reflect" + "slices" + "strings" + "sync" + + "github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1" + "github.com/actions/actions-runner-controller/hash" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var resourceCacheObjectTypes sync.Map + +type ResourceCacheObjectRef struct { + ObjectType string + Namespace string + Name string + UID types.UID + ResourceVersion string +} + +type ResourceCacheKey struct { + MainUID types.UID + Namespace string + Name string +} + +type ResourceCacheValue[T client.Object] struct { + MainObject ResourceCacheObjectRef + ResourceVersion string + Dependencies []ResourceCacheObjectRef + Object T +} + +type ResourceCache struct { + autoscalingListener *resourceCacheState[*v1alpha1.AutoscalingListener] + ephemeralRunnerSet *resourceCacheState[*v1alpha1.EphemeralRunnerSet] + listenerPod *resourceCacheState[*corev1.Pod] + listenerServiceAccount *resourceCacheState[*corev1.ServiceAccount] + listenerRole *resourceCacheState[*rbacv1.Role] + listenerRoleBinding *resourceCacheState[*rbacv1.RoleBinding] +} + +func NewResourceCache() ResourceCache { + return ResourceCache{ + autoscalingListener: newResourceCacheState[*v1alpha1.AutoscalingListener](), + ephemeralRunnerSet: newResourceCacheState[*v1alpha1.EphemeralRunnerSet](), + listenerPod: newResourceCacheState[*corev1.Pod](), + listenerServiceAccount: newResourceCacheState[*corev1.ServiceAccount](), + listenerRole: newResourceCacheState[*rbacv1.Role](), + listenerRoleBinding: newResourceCacheState[*rbacv1.RoleBinding](), + } +} + +type resourceCacheState[T client.Object] struct { + mu sync.RWMutex + entries map[ResourceCacheKey]ResourceCacheValue[T] +} + +func newResourceCacheState[T client.Object]() *resourceCacheState[T] { + return &resourceCacheState[T]{ + entries: make(map[ResourceCacheKey]ResourceCacheValue[T], 512), + } +} + +func (s *resourceCacheState[T]) Get( + mainObject client.Object, + desiredObject T, + dependencies ...client.Object, +) (T, bool) { + key := newResourceCacheKey(mainObject, desiredObject) + + s.mu.RLock() + value, ok := s.entries[key] + s.mu.RUnlock() + if !ok || !value.Matches(mainObject, dependencies...) { + var zero T + return zero, false + } + + return cloneResourceCacheObject(value.Object), true +} + +func (s *resourceCacheState[T]) Upsert( + mainObject client.Object, + desiredObject T, + dependencies ...client.Object, +) (ResourceCacheValue[T], bool) { + key := newResourceCacheKey(mainObject, desiredObject) + mainObjectRef := newResourceCacheObjectRef(mainObject) + resourceVersion := desiredObject.GetResourceVersion() + + s.mu.RLock() + previous, ok := s.entries[key] + if ok && previous.MainObject == mainObjectRef && previous.ResourceVersion == resourceVersion && previous.dependenciesMatch(dependencies...) { + s.mu.RUnlock() + return previous, false + } + s.mu.RUnlock() + + s.mu.Lock() + defer s.mu.Unlock() + + previous, ok = s.entries[key] + if ok && previous.MainObject == mainObjectRef && previous.ResourceVersion == resourceVersion && previous.dependenciesMatch(dependencies...) { + return previous, false + } + + dependencyRefs := newResourceCacheObjectRefs(dependencies...) + value := newResourceCacheValue(mainObjectRef, resourceVersion, dependencyRefs, cloneResourceCacheObject(desiredObject)) + s.entries[key] = value + return value, true +} + +func (c *ResourceCache) Delete(mainObject client.Object) { + if mainObject == nil { + return + } + + c.autoscalingListener.Delete(mainObject) + c.ephemeralRunnerSet.Delete(mainObject) + c.listenerPod.Delete(mainObject) + c.listenerServiceAccount.Delete(mainObject) + c.listenerRole.Delete(mainObject) + c.listenerRoleBinding.Delete(mainObject) +} + +func (s *resourceCacheState[T]) Delete(mainObject client.Object) { + if mainObject == nil { + return + } + + uid := mainObject.GetUID() + if uid == "" { + return + } + + s.mu.Lock() + defer s.mu.Unlock() + + for key := range s.entries { + if key.MainUID == uid { + delete(s.entries, key) + } + } +} + +func (v ResourceCacheValue[T]) Matches(mainObject client.Object, dependencies ...client.Object) bool { + if v.MainObject != newResourceCacheObjectRef(mainObject) { + return false + } + + return v.dependenciesMatch(dependencies...) +} + +func newResourceCacheKey(mainObject client.Object, desiredObject client.Object) ResourceCacheKey { + return ResourceCacheKey{ + MainUID: mainObject.GetUID(), + Namespace: desiredObject.GetNamespace(), + Name: resourceCacheObjectName(desiredObject), + } +} + +func newResourceCacheValue[T client.Object]( + mainObjectRef ResourceCacheObjectRef, + resourceVersion string, + dependencyRefs []ResourceCacheObjectRef, + object T, +) ResourceCacheValue[T] { + return ResourceCacheValue[T]{ + MainObject: mainObjectRef, + ResourceVersion: resourceVersion, + Dependencies: dependencyRefs, + Object: object, + } +} + +func cloneResourceCacheObject[T client.Object](object T) T { + return object.DeepCopyObject().(T) +} + +func newResourceCacheObjectRefs(objects ...client.Object) []ResourceCacheObjectRef { + refs := make([]ResourceCacheObjectRef, 0, len(objects)) + for _, object := range objects { + refs = append(refs, newResourceCacheObjectRef(object)) + } + slices.SortFunc(refs, func(a, b ResourceCacheObjectRef) int { + return compareResourceCacheObjectRefs(a, b) + }) + return refs +} + +func (v ResourceCacheValue[T]) dependenciesMatch(objects ...client.Object) bool { + if len(v.Dependencies) != len(objects) { + return false + } + + for _, object := range objects { + ref := newResourceCacheObjectRef(object) + if !slices.Contains(v.Dependencies, ref) { + return false + } + } + + return true +} + +func newResourceCacheObjectRef(object client.Object) ResourceCacheObjectRef { + resourceVersion := object.GetResourceVersion() + if resourceVersion == "" { + resourceVersion = hash.ComputeTemplateHash(object) + } + + return ResourceCacheObjectRef{ + ObjectType: resourceCacheObjectType(object), + Namespace: object.GetNamespace(), + Name: resourceCacheObjectName(object), + UID: object.GetUID(), + ResourceVersion: resourceVersion, + } +} + +func compareResourceCacheObjectRefs(a, b ResourceCacheObjectRef) int { + if c := strings.Compare(a.ObjectType, b.ObjectType); c != 0 { + return c + } + if c := strings.Compare(a.Namespace, b.Namespace); c != 0 { + return c + } + if c := strings.Compare(a.Name, b.Name); c != 0 { + return c + } + if c := strings.Compare(string(a.UID), string(b.UID)); c != 0 { + return c + } + return strings.Compare(a.ResourceVersion, b.ResourceVersion) +} + +func resourceCacheObjectType(object client.Object) string { + t := reflect.TypeOf(object) + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + if objectType, ok := resourceCacheObjectTypes.Load(t); ok { + return objectType.(string) + } + + objectType := t.PkgPath() + "." + t.Name() + actual, _ := resourceCacheObjectTypes.LoadOrStore(t, objectType) + return actual.(string) +} + +func resourceCacheObjectName(object client.Object) string { + if object.GetName() != "" { + return object.GetName() + } + return object.GetGenerateName() +} diff --git a/controllers/actions.github.com/resourcecache_test.go b/controllers/actions.github.com/resourcecache_test.go new file mode 100644 index 0000000000..0fe7aeb587 --- /dev/null +++ b/controllers/actions.github.com/resourcecache_test.go @@ -0,0 +1,351 @@ +package actionsgithubcom + +import ( + "fmt" + "testing" + + "github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var benchmarkEphemeralRunnerSetSink *v1alpha1.EphemeralRunnerSet + +func newTestResourceCache() *ResourceCache { + cache := NewResourceCache() + return &cache +} + +func TestResourceCacheUpsertReplacesByDependencyResourceVersion(t *testing.T) { + mainObject := &v1alpha1.AutoscalingListener{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "controller-ns", + UID: "listener-uid", + ResourceVersion: "10", + }, + } + desiredPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "controller-ns", + ResourceVersion: "1", + Labels: map[string]string{ + "app": "listener", + }, + }, + } + configSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener-config", + Namespace: "controller-ns", + UID: "config-secret-uid", + ResourceVersion: "1", + }, + } + serviceAccount := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "controller-ns", + UID: "service-account-uid", + ResourceVersion: "1", + }, + } + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "scale-set-ns", + UID: "role-uid", + ResourceVersion: "1", + }, + } + + cache := NewResourceCache() + value, replaced := cache.listenerPod.Upsert(mainObject, desiredPod, configSecret, serviceAccount, role) + assert.True(t, replaced) + _, ok := cache.listenerPod.Get(mainObject, desiredPod, configSecret, serviceAccount, role) + assert.True(t, ok) + assert.Equal(t, "1", value.ResourceVersion) + + _, replaced = cache.listenerPod.Upsert(mainObject, desiredPod, role, configSecret, serviceAccount) + assert.False(t, replaced, "dependency ordering should not affect the cache value") + _, ok = cache.listenerPod.Get(mainObject, desiredPod, configSecret, serviceAccount, role) + assert.True(t, ok) + + configSecret.ResourceVersion = "2" + value, replaced = cache.listenerPod.Upsert(mainObject, desiredPod, configSecret, serviceAccount, role) + assert.True(t, replaced) + assert.Contains(t, value.Dependencies, ResourceCacheObjectRef{ + ObjectType: resourceCacheObjectType(configSecret), + Namespace: "controller-ns", + Name: "listener-config", + UID: "config-secret-uid", + ResourceVersion: "2", + }) + + desiredPod.Labels["mutated"] = "after-cache" + cachedPod := value.Object + assert.NotContains(t, cachedPod.Labels, "mutated") +} + +func TestResourceCacheDeleteRemovesMainObjectEntries(t *testing.T) { + mainObject := &v1alpha1.AutoscalingListener{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "controller-ns", + UID: "listener-uid", + }, + } + otherMainObject := &v1alpha1.AutoscalingListener{ + ObjectMeta: metav1.ObjectMeta{ + Name: "other-listener", + Namespace: "controller-ns", + UID: "other-listener-uid", + }, + } + listenerPod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "listener", Namespace: "controller-ns"}} + listenerServiceAccount := &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "listener", Namespace: "controller-ns"}} + otherListenerPod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "other-listener", Namespace: "controller-ns"}} + + cache := NewResourceCache() + cache.listenerPod.Upsert(mainObject, listenerPod) + cache.listenerServiceAccount.Upsert(mainObject, listenerServiceAccount) + cache.listenerPod.Upsert(otherMainObject, otherListenerPod) + + cache.Delete(mainObject) + + _, ok := cache.listenerPod.Get(mainObject, listenerPod) + assert.False(t, ok) + _, ok = cache.listenerServiceAccount.Get(mainObject, listenerServiceAccount) + assert.False(t, ok) + _, ok = cache.listenerPod.Get(otherMainObject, otherListenerPod) + assert.True(t, ok) +} + +func TestResourceBuilderCachesListenerPodDependencies(t *testing.T) { + listener := &v1alpha1.AutoscalingListener{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "controller-ns", + UID: "listener-uid", + Annotations: map[string]string{ + annotationKeyIntegrityHash: "listener-hash", + }, + }, + Spec: v1alpha1.AutoscalingListenerSpec{ + Image: "listener:latest", + AutoscalingRunnerSetName: "scale-set", + AutoscalingRunnerSetNamespace: "scale-set-ns", + EphemeralRunnerSetName: "scale-set", + }, + } + podConfig := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener-config", + Namespace: "controller-ns", + UID: "config-secret-uid", + ResourceVersion: "11", + Annotations: map[string]string{ + annotationKeyIntegrityHash: "config-hash", + }, + }, + } + serviceAccount := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "controller-ns", + UID: "service-account-uid", + ResourceVersion: "12", + Annotations: map[string]string{ + annotationKeyIntegrityHash: "service-account-hash", + }, + }, + } + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "scale-set-ns", + UID: "role-uid", + ResourceVersion: "13", + Annotations: map[string]string{ + annotationKeyIntegrityHash: "role-hash", + }, + }, + } + roleBinding := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "scale-set-ns", + UID: "role-binding-uid", + ResourceVersion: "14", + Annotations: map[string]string{ + annotationKeyIntegrityHash: "role-binding-hash", + }, + }, + } + + cache := NewResourceCache() + b := ResourceBuilder{ResourceCache: &cache} + listenerPod, err := b.newScaleSetListenerPod(listener, podConfig, serviceAccount, role, roleBinding, nil) + require.NoError(t, err) + + cachedPod, ok := b.ResourceCache.listenerPod.Get(listener, listenerPod, podConfig, serviceAccount, role, roleBinding) + require.True(t, ok) + assert.IsType(t, &corev1.Pod{}, cachedPod) + + role.ResourceVersion = "changed" + _, ok = b.ResourceCache.listenerPod.Get(listener, listenerPod, podConfig, serviceAccount, role, roleBinding) + assert.False(t, ok) +} + +func TestResourceBuilderCachesEphemeralRunnerSet(t *testing.T) { + autoscalingRunnerSet := v1alpha1.AutoscalingRunnerSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "scale-set", + Namespace: "default", + UID: "scale-set-uid", + Annotations: map[string]string{ + runnerScaleSetIDAnnotationKey: "1", + }, + }, + Spec: v1alpha1.AutoscalingRunnerSetSpec{ + GitHubConfigUrl: "https://github.com/actions/actions-runner-controller", + }, + } + + cache := NewResourceCache() + b := ResourceBuilder{ResourceCache: &cache} + runnerSet, err := b.newEphemeralRunnerSet(&autoscalingRunnerSet) + require.NoError(t, err) + + cachedRunnerSet, ok := b.ResourceCache.ephemeralRunnerSet.Get(&autoscalingRunnerSet, runnerSet) + require.True(t, ok) + assert.Equal(t, runnerSet.Spec, cachedRunnerSet.Spec) + + runnerSet.Labels["mutated"] = "after-cache" + assert.NotContains(t, cachedRunnerSet.Labels, "mutated") + + fromBuilder, err := b.newEphemeralRunnerSet(&autoscalingRunnerSet) + require.NoError(t, err) + assert.NotContains(t, fromBuilder.Labels, "mutated") + + autoscalingRunnerSet.Annotations[runnerScaleSetIDAnnotationKey] = "2" + _, ok = b.ResourceCache.ephemeralRunnerSet.Get(&autoscalingRunnerSet, runnerSet) + assert.False(t, ok) +} + +func BenchmarkNewEphemeralRunnerSetResourceCache(b *testing.B) { + autoscalingRunnerSet := newBenchmarkAutoscalingRunnerSet() + + b.Run("no_cache", func(b *testing.B) { + builder := ResourceBuilder{} + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + runnerSet, err := builder.newEphemeralRunnerSet(autoscalingRunnerSet) + if err != nil { + b.Fatal(err) + } + benchmarkEphemeralRunnerSetSink = runnerSet + } + }) + + b.Run("cache_hit", func(b *testing.B) { + cache := NewResourceCache() + builder := ResourceBuilder{ResourceCache: &cache} + if _, err := builder.newEphemeralRunnerSet(autoscalingRunnerSet); err != nil { + b.Fatal(err) + } + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + runnerSet, err := builder.newEphemeralRunnerSet(autoscalingRunnerSet) + if err != nil { + b.Fatal(err) + } + benchmarkEphemeralRunnerSetSink = runnerSet + } + }) + + b.Run("cache_miss", func(b *testing.B) { + cache := NewResourceCache() + builder := ResourceBuilder{ResourceCache: &cache} + autoscalingRunnerSet := autoscalingRunnerSet.DeepCopy() + + b.ReportAllocs() + b.ResetTimer() + + for i := 0; i < b.N; i++ { + autoscalingRunnerSet.ResourceVersion = fmt.Sprint(i) + runnerSet, err := builder.newEphemeralRunnerSet(autoscalingRunnerSet) + if err != nil { + b.Fatal(err) + } + benchmarkEphemeralRunnerSetSink = runnerSet + } + }) +} + +func newBenchmarkAutoscalingRunnerSet() *v1alpha1.AutoscalingRunnerSet { + return &v1alpha1.AutoscalingRunnerSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "benchmark-scale-set", + Namespace: "benchmark-namespace", + UID: "benchmark-scale-set-uid", + ResourceVersion: "1", + Labels: map[string]string{ + LabelKeyKubernetesVersion: "0.12.0", + "example.com/label-1": "value-1", + "example.com/label-2": "value-2", + }, + Annotations: map[string]string{ + runnerScaleSetIDAnnotationKey: "123", + AnnotationKeyGitHubRunnerGroupName: "benchmark-runner-group", + AnnotationKeyGitHubRunnerScaleSetName: "benchmark-scale-set", + }, + }, + Spec: v1alpha1.AutoscalingRunnerSetSpec{ + GitHubConfigUrl: "https://github.com/actions/actions-runner-controller", + EphemeralRunnerSetMetadata: &v1alpha1.ResourceMeta{ + Labels: map[string]string{ + "example.com/runner-set-label": "runner-set-value", + }, + Annotations: map[string]string{ + "example.com/runner-set-annotation": "runner-set-value", + }, + }, + EphemeralRunnerMetadata: &v1alpha1.ResourceMeta{ + Labels: map[string]string{ + "example.com/runner-label": "runner-value", + }, + Annotations: map[string]string{ + "example.com/runner-annotation": "runner-value", + }, + }, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "example.com/template-label": "template-value", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: v1alpha1.EphemeralRunnerContainerName, + Image: "ghcr.io/actions/actions-runner:latest", + Env: []corev1.EnvVar{ + {Name: "ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER", Value: "false"}, + }, + }, + }, + }, + }, + }, + } +} diff --git a/main.go b/main.go index 72c65f6bbb..c22fe7706c 100644 --- a/main.go +++ b/main.go @@ -299,10 +299,12 @@ func main() { secretresolver.WithLogger(slogLogger), ) + resourceCache := actionsgithubcom.NewResourceCache() rb := actionsgithubcom.ResourceBuilder{ ExcludeLabelPropagationPrefixes: excludeLabelPropagationPrefixes, SecretResolver: secretResolver, Scheme: mgr.GetScheme(), + ResourceCache: &resourceCache, } log.Info("Resource builder initializing") From a47cae6f494102a9387310252dcaeb93f8909dcc Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Wed, 15 Jul 2026 13:44:04 +0200 Subject: [PATCH 21/31] wip --- controllers/actions.github.com/resourcecache_test.go | 7 +++++++ main.go | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/controllers/actions.github.com/resourcecache_test.go b/controllers/actions.github.com/resourcecache_test.go index 0fe7aeb587..8bf576d539 100644 --- a/controllers/actions.github.com/resourcecache_test.go +++ b/controllers/actions.github.com/resourcecache_test.go @@ -125,6 +125,13 @@ func TestResourceCacheDeleteRemovesMainObjectEntries(t *testing.T) { assert.True(t, ok) } +func TestResourceCacheDeletePanicsWithNilCache(t *testing.T) { + var cache *ResourceCache + assert.Panics(t, func() { + cache.Delete(&v1alpha1.AutoscalingListener{}) + }) +} + func TestResourceBuilderCachesListenerPodDependencies(t *testing.T) { listener := &v1alpha1.AutoscalingListener{ ObjectMeta: metav1.ObjectMeta{ diff --git a/main.go b/main.go index c22fe7706c..5ec4a56645 100644 --- a/main.go +++ b/main.go @@ -218,6 +218,7 @@ func main() { } actionsgithubcom.SetListenerEntrypoint(os.Getenv("LISTENER_ENTRYPOINT")) + resourceCache := actionsgithubcom.NewResourceCache() var webhookServer webhook.Server if port != 0 { @@ -299,7 +300,6 @@ func main() { secretresolver.WithLogger(slogLogger), ) - resourceCache := actionsgithubcom.NewResourceCache() rb := actionsgithubcom.ResourceBuilder{ ExcludeLabelPropagationPrefixes: excludeLabelPropagationPrefixes, SecretResolver: secretResolver, From 4670f010ed011afd69550125721acba73e870bb1 Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Wed, 15 Jul 2026 15:04:18 +0200 Subject: [PATCH 22/31] remove if checks, testing should catch nil references --- .../autoscalinglistener_controller.go | 4 + .../autoscalinglistener_controller_test.go | 42 +++- .../autoscalingrunnerset_controller.go | 2 + .../autoscalingrunnerset_controller_test.go | 36 +++- .../ephemeralrunner_controller_test.go | 20 +- .../ephemeralrunnerset_controller_test.go | 14 +- .../actions.github.com/resourcebuilder.go | 91 +++++---- .../resourcebuilder_test.go | 14 +- .../actions.github.com/resourcecache.go | 171 ++++++++++------ .../actions.github.com/resourcecache_test.go | 192 ++++++------------ 10 files changed, 338 insertions(+), 248 deletions(-) diff --git a/controllers/actions.github.com/autoscalinglistener_controller.go b/controllers/actions.github.com/autoscalinglistener_controller.go index af19a6f2fe..38e8d64fdb 100644 --- a/controllers/actions.github.com/autoscalinglistener_controller.go +++ b/controllers/actions.github.com/autoscalinglistener_controller.go @@ -501,6 +501,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, nil } + r.ResourceCache.listenerPod.Delete(&autoscalingListener) desiredPod, err := r.newScaleSetListenerPod( &autoscalingListener, &listenerConfigSecret, @@ -686,6 +687,7 @@ func (r *AutoscalingListenerReconciler) cleanupResources(ctx context.Context, au } func (r *AutoscalingListenerReconciler) createServiceAccountForListener(ctx context.Context, autoscalingListener *v1alpha1.AutoscalingListener, logger logr.Logger) (ctrl.Result, error) { + r.ResourceCache.listenerServiceAccount.Delete(autoscalingListener) newServiceAccount, err := r.newScaleSetListenerServiceAccount(autoscalingListener) if err != nil { return ctrl.Result{}, err @@ -769,6 +771,7 @@ func (r *AutoscalingListenerReconciler) createProxySecret(ctx context.Context, a } func (r *AutoscalingListenerReconciler) createRoleForListener(ctx context.Context, autoscalingListener *v1alpha1.AutoscalingListener, logger logr.Logger) (ctrl.Result, error) { + r.ResourceCache.listenerRole.Delete(autoscalingListener) newRole := r.newScaleSetListenerRole(autoscalingListener) logger.Info("Creating listener role", "namespace", newRole.Namespace, "name", newRole.Name, "rules", newRole.Rules) @@ -782,6 +785,7 @@ func (r *AutoscalingListenerReconciler) createRoleForListener(ctx context.Contex } func (r *AutoscalingListenerReconciler) createRoleBindingForListener(ctx context.Context, autoscalingListener *v1alpha1.AutoscalingListener, listenerRole *rbacv1.Role, serviceAccount *corev1.ServiceAccount, logger logr.Logger) (ctrl.Result, error) { + r.ResourceCache.listenerRoleBinding.Delete(autoscalingListener) newRoleBinding := r.newScaleSetListenerRoleBinding(autoscalingListener, listenerRole, serviceAccount) logger.Info("Creating listener role binding", diff --git a/controllers/actions.github.com/autoscalinglistener_controller_test.go b/controllers/actions.github.com/autoscalinglistener_controller_test.go index 83a499c4f9..67f7ecf5e7 100644 --- a/controllers/actions.github.com/autoscalinglistener_controller_test.go +++ b/controllers/actions.github.com/autoscalinglistener_controller_test.go @@ -38,6 +38,7 @@ var _ = Describe("Test AutoScalingListener controller", func() { var autoscalingRunnerSet *v1alpha1.AutoscalingRunnerSet var configSecret *corev1.Secret var autoscalingListener *v1alpha1.AutoscalingListener + var resourceCache *ResourceCache BeforeEach(func() { ctx = context.Background() @@ -49,8 +50,9 @@ var _ = Describe("Test AutoScalingListener controller", func() { scalefake.NewMultiClient(), ) + resourceCache = newTestResourceCache() rb := ResourceBuilder{ - ResourceCache: newTestResourceCache(), + ResourceCache: resourceCache, SecretResolver: secretResolver, } @@ -231,6 +233,17 @@ var _ = Describe("Test AutoScalingListener controller", func() { autoscalingListenerTestTimeout, autoscalingListenerTestInterval, ).Should(BeEquivalentTo(autoscalingListener.Name), "Pod should be created") + + Eventually( + func() bool { + return resourceCacheStateHasMainObjectEntries(resourceCache.listenerServiceAccount, created) && + resourceCacheStateHasMainObjectEntries(resourceCache.listenerRole, created) && + resourceCacheStateHasMainObjectEntries(resourceCache.listenerRoleBinding, created) && + resourceCacheStateHasMainObjectEntries(resourceCache.listenerPod, created) + }, + autoscalingListenerTestTimeout, + autoscalingListenerTestInterval, + ).Should(BeTrue(), "AutoScalingListener service account, role, role binding, and pod resources should be cached after reconciliation") }) }) @@ -251,8 +264,22 @@ var _ = Describe("Test AutoScalingListener controller", func() { autoscalingListenerTestInterval, ).Should(BeEquivalentTo(autoscalingListener.Name), "Pod should be created") + created := new(v1alpha1.AutoscalingListener) + err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, created) + Expect(err).NotTo(HaveOccurred(), "failed to get AutoScalingListener") + Eventually( + func() bool { + return resourceCacheStateHasMainObjectEntries(resourceCache.listenerServiceAccount, created) && + resourceCacheStateHasMainObjectEntries(resourceCache.listenerRole, created) && + resourceCacheStateHasMainObjectEntries(resourceCache.listenerRoleBinding, created) && + resourceCacheStateHasMainObjectEntries(resourceCache.listenerPod, created) + }, + autoscalingListenerTestTimeout, + autoscalingListenerTestInterval, + ).Should(BeTrue(), "AutoScalingListener service account, role, role binding, and pod resources should be cached before deletion") + // Delete the AutoScalingListener - err := k8sClient.Delete(ctx, autoscalingListener) + err = k8sClient.Delete(ctx, autoscalingListener) Expect(err).NotTo(HaveOccurred(), "failed to delete test AutoScalingListener") // Cleanup the listener pod @@ -343,6 +370,17 @@ var _ = Describe("Test AutoScalingListener controller", func() { autoscalingListenerTestTimeout, autoscalingListenerTestInterval, ).ShouldNot(Succeed(), "failed to delete AutoScalingListener") + + Eventually( + func() bool { + return resourceCacheStateHasMainObjectEntries(resourceCache.listenerServiceAccount, created) || + resourceCacheStateHasMainObjectEntries(resourceCache.listenerRole, created) || + resourceCacheStateHasMainObjectEntries(resourceCache.listenerRoleBinding, created) || + resourceCacheStateHasMainObjectEntries(resourceCache.listenerPod, created) + }, + autoscalingListenerTestTimeout, + autoscalingListenerTestInterval, + ).Should(BeFalse(), "AutoScalingListener service account, role, role binding, and pod resources should be removed from cache after deletion") }) }) diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller.go b/controllers/actions.github.com/autoscalingrunnerset_controller.go index 9d56ffc711..08c9ebc960 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller.go @@ -749,6 +749,7 @@ func (r *AutoscalingRunnerSetReconciler) deleteRunnerScaleSet(ctx context.Contex } func (r *AutoscalingRunnerSetReconciler) createEphemeralRunnerSet(ctx context.Context, autoscalingRunnerSet *v1alpha1.AutoscalingRunnerSet, log logr.Logger) (ctrl.Result, error) { + r.ResourceCache.ephemeralRunnerSet.Delete(autoscalingRunnerSet) desiredRunnerSet, err := r.newEphemeralRunnerSet(autoscalingRunnerSet) if err != nil { log.Error(err, "Could not create EphemeralRunnerSet") @@ -773,6 +774,7 @@ func (r *AutoscalingRunnerSetReconciler) createAutoScalingListenerForRunnerSet(c }) } + r.ResourceCache.autoscalingListener.Delete(autoscalingRunnerSet) autoscalingListener, err := r.newAutoscalingListener( autoscalingRunnerSet, ephemeralRunnerSet, diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go index 82177acb4c..3cbe719fc8 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go @@ -44,6 +44,7 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { var autoscalingNS *corev1.Namespace var autoscalingRunnerSet *v1alpha1.AutoscalingRunnerSet var configSecret *corev1.Secret + var resourceCache *ResourceCache var originalBuildVersion string buildVersion := "0.1.0" @@ -65,6 +66,7 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { // Track runner group mappings for dynamic responses runnerGroupMap := map[int]string{1: "testgroup"} // ID -> Name mapping runnerGroupMapLock := &sync.RWMutex{} // Thread-safe access + resourceCache = newTestResourceCache() controller = &AutoscalingRunnerSetReconciler{ Client: mgr.GetClient(), @@ -73,7 +75,7 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { ControllerNamespace: autoscalingNS.Name, DefaultRunnerScaleSetListenerImage: "ghcr.io/actions/arc", ResourceBuilder: ResourceBuilder{ - ResourceCache: newTestResourceCache(), + ResourceCache: resourceCache, SecretResolver: secretresolver.New(mgr.GetClient(), scalefake.NewMultiClient( scalefake.WithClient( scalefake.NewClient( @@ -252,6 +254,15 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { autoscalingRunnerSetTestInterval, ).Should(Succeed(), "Listener should be created") + Eventually( + func() bool { + return resourceCacheStateHasMainObjectEntries(resourceCache.ephemeralRunnerSet, created) && + resourceCacheStateHasMainObjectEntries(resourceCache.autoscalingListener, created) + }, + autoscalingRunnerSetTestTimeout, + autoscalingRunnerSetTestInterval, + ).Should(BeTrue(), "AutoScalingRunnerSet EphemeralRunnerSet and AutoScalingListener resources should be cached after reconciliation") + // Check if status is updated runnerSetList := new(v1alpha1.EphemeralRunnerSetList) err := k8sClient.List(ctx, runnerSetList, client.InNamespace(autoscalingRunnerSet.Namespace)) @@ -271,8 +282,20 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { autoscalingRunnerSetTestInterval, ).Should(Succeed(), "Listener should be created") + created := new(v1alpha1.AutoscalingRunnerSet) + err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, created) + Expect(err).NotTo(HaveOccurred(), "failed to get AutoScalingRunnerSet") + Eventually( + func() bool { + return resourceCacheStateHasMainObjectEntries(resourceCache.ephemeralRunnerSet, created) && + resourceCacheStateHasMainObjectEntries(resourceCache.autoscalingListener, created) + }, + autoscalingRunnerSetTestTimeout, + autoscalingRunnerSetTestInterval, + ).Should(BeTrue(), "AutoScalingRunnerSet EphemeralRunnerSet and AutoScalingListener resources should be cached before deletion") + // Delete the AutoScalingRunnerSet - err := k8sClient.Delete(ctx, autoscalingRunnerSet) + err = k8sClient.Delete(ctx, autoscalingRunnerSet) Expect(err).NotTo(HaveOccurred(), "failed to delete AutoScalingRunnerSet") // Check if the listener is deleted @@ -321,6 +344,15 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, ).Should(Succeed(), "AutoScalingRunnerSet should be deleted") + + Eventually( + func() bool { + return resourceCacheStateHasMainObjectEntries(resourceCache.ephemeralRunnerSet, created) || + resourceCacheStateHasMainObjectEntries(resourceCache.autoscalingListener, created) + }, + autoscalingRunnerSetTestTimeout, + autoscalingRunnerSetTestInterval, + ).Should(BeFalse(), "AutoScalingRunnerSet EphemeralRunnerSet and AutoScalingListener resources should be removed from cache after deletion") }) }) diff --git a/controllers/actions.github.com/ephemeralrunner_controller_test.go b/controllers/actions.github.com/ephemeralrunner_controller_test.go index ddd666bb24..74f9fe9923 100644 --- a/controllers/actions.github.com/ephemeralrunner_controller_test.go +++ b/controllers/actions.github.com/ephemeralrunner_controller_test.go @@ -100,18 +100,20 @@ var _ = Describe("EphemeralRunner", func() { var configSecret *corev1.Secret var controller *EphemeralRunnerReconciler var ephemeralRunner *v1alpha1.EphemeralRunner + var resourceCache *ResourceCache BeforeEach(func() { ctx = context.Background() autoscalingNS, mgr = createNamespace(GinkgoT(), k8sClient) configSecret = createDefaultSecret(GinkgoT(), k8sClient, autoscalingNS.Name) + resourceCache = newTestResourceCache() controller = &EphemeralRunnerReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Log: logf.Log, ResourceBuilder: ResourceBuilder{ - ResourceCache: newTestResourceCache(), + ResourceCache: resourceCache, SecretResolver: secretresolver.New(mgr.GetClient(), scalefake.NewMultiClient( scalefake.WithClient( scalefake.NewClient( @@ -652,6 +654,12 @@ var _ = Describe("EphemeralRunner", func() { return true, nil }).Should(BeEquivalentTo(true)) + created := new(v1alpha1.EphemeralRunner) + err := k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunner.Name, Namespace: ephemeralRunner.Namespace}, created) + Expect(err).To(BeNil(), "failed to get ephemeral runner") + resourceCache.listenerPod.Upsert(created, &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "cached-runner-pod", Namespace: created.Namespace}}) + Expect(resourceCacheHasMainObjectEntries(resourceCache, created)).To(BeTrue(), "test setup should cache an EphemeralRunner-owned resource") + // create runner-linked pod runnerLinkedPod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ @@ -671,7 +679,7 @@ var _ = Describe("EphemeralRunner", func() { }, } - err := k8sClient.Create(ctx, runnerLinkedPod) + err = k8sClient.Create(ctx, runnerLinkedPod) Expect(err).To(BeNil(), "failed to create runner linked pod") Eventually( func() (bool, error) { @@ -778,6 +786,14 @@ var _ = Describe("EphemeralRunner", func() { ephemeralRunnerTimeout, ephemeralRunnerInterval, ).Should(BeEquivalentTo(true)) + + Eventually( + func() bool { + return resourceCacheHasMainObjectEntries(resourceCache, created) + }, + ephemeralRunnerTimeout, + ephemeralRunnerInterval, + ).Should(BeFalse(), "EphemeralRunner-owned resources should be removed from cache after deletion") }) It("It should eventually have runner id set", func() { diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go index 2ee42ac274..331e8e596c 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go @@ -133,18 +133,20 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { var autoscalingNS *corev1.Namespace var ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet var configSecret *corev1.Secret + var resourceCache *ResourceCache BeforeEach(func() { ctx = context.Background() autoscalingNS, mgr = createNamespace(GinkgoT(), k8sClient) configSecret = createDefaultSecret(GinkgoT(), k8sClient, autoscalingNS.Name) + resourceCache = newTestResourceCache() controller := &EphemeralRunnerSetReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Log: logf.Log, ResourceBuilder: ResourceBuilder{ - ResourceCache: newTestResourceCache(), + ResourceCache: resourceCache, SecretResolver: secretresolver.New(mgr.GetClient(), fake.NewMultiClient( fake.WithClient( fake.NewClient( @@ -284,6 +286,8 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { created := new(v1alpha1.EphemeralRunnerSet) err := k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunnerSet.Name, Namespace: ephemeralRunnerSet.Namespace}, created) Expect(err).NotTo(HaveOccurred(), "failed to get EphemeralRunnerSet") + resourceCache.listenerPod.Upsert(created, &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "cached-runner-set-pod", Namespace: created.Namespace}}) + Expect(resourceCacheHasMainObjectEntries(resourceCache, created)).To(BeTrue(), "test setup should cache an EphemeralRunnerSet-owned resource") // Scale up the EphemeralRunnerSet updated := created.DeepCopy() @@ -360,6 +364,14 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { ephemeralRunnerSetTestTimeout, ephemeralRunnerSetTestInterval, ).Should(Succeed(), "EphemeralRunnerSet should be deleted") + + Eventually( + func() bool { + return resourceCacheHasMainObjectEntries(resourceCache, created) + }, + ephemeralRunnerSetTestTimeout, + ephemeralRunnerSetTestInterval, + ).Should(BeFalse(), "EphemeralRunnerSet-owned resources should be removed from cache after deletion") }) }) diff --git a/controllers/actions.github.com/resourcebuilder.go b/controllers/actions.github.com/resourcebuilder.go index 8d6ae26cb6..a75d414c57 100644 --- a/controllers/actions.github.com/resourcebuilder.go +++ b/controllers/actions.github.com/resourcebuilder.go @@ -137,10 +137,8 @@ func (b *ResourceBuilder) newAutoscalingListener(autoscalingRunnerSet *v1alpha1. Image: image, ImagePullSecrets: imagePullSecrets, }) - if b.ResourceCache != nil { - if cached, ok := b.ResourceCache.autoscalingListener.Get(autoscalingRunnerSet, cacheKeyObject, ephemeralRunnerSet, inputDependency); ok { - return cached, nil - } + if cached, ok := b.ResourceCache.autoscalingListener.Get(autoscalingRunnerSet, cacheKeyObject, ephemeralRunnerSet, inputDependency); ok { + return cached, nil } effectiveMinRunners := 0 @@ -196,6 +194,10 @@ func (b *ResourceBuilder) newAutoscalingListener(autoscalingRunnerSet *v1alpha1. } autoscalingListener := &v1alpha1.AutoscalingListener{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1alpha1.GroupVersion.String(), + Kind: "AutoscalingListener", + }, ObjectMeta: metav1.ObjectMeta{ Name: scaleSetListenerName(autoscalingRunnerSet), Namespace: namespace, @@ -204,15 +206,17 @@ func (b *ResourceBuilder) newAutoscalingListener(autoscalingRunnerSet *v1alpha1. }, Spec: spec, } - if b.ResourceCache != nil { - b.ResourceCache.autoscalingListener.Upsert(autoscalingRunnerSet, autoscalingListener, ephemeralRunnerSet, inputDependency) - } + b.ResourceCache.autoscalingListener.Upsert(autoscalingRunnerSet, autoscalingListener, ephemeralRunnerSet, inputDependency) return autoscalingListener, nil } func resourceCacheInputObject(name string, value any) client.Object { return &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{ + APIVersion: corev1.SchemeGroupVersion.String(), + Kind: "ConfigMap", + }, ObjectMeta: metav1.ObjectMeta{ Name: name, ResourceVersion: hash.ComputeTemplateHash(value), @@ -301,6 +305,10 @@ func (b *ResourceBuilder) newScaleSetListenerConfig(autoscalingListener *v1alpha } desiredSecret := &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + APIVersion: corev1.SchemeGroupVersion.String(), + Kind: "Secret", + }, ObjectMeta: metav1.ObjectMeta{ Name: scaleSetListenerConfigName(autoscalingListener), Namespace: autoscalingListener.Namespace, @@ -347,10 +355,8 @@ func (b *ResourceBuilder) newScaleSetListenerPod( Namespace: autoscalingListener.Namespace, }, } - if b.ResourceCache != nil { - if cached, ok := b.ResourceCache.listenerPod.Get(autoscalingListener, cacheKeyObject, podConfig, serviceAccount, role, roleBinding); ok { - return cached, nil - } + if cached, ok := b.ResourceCache.listenerPod.Get(autoscalingListener, cacheKeyObject, podConfig, serviceAccount, role, roleBinding); ok { + return cached, nil } envs := []corev1.EnvVar{ @@ -460,8 +466,8 @@ func (b *ResourceBuilder) newScaleSetListenerPod( newRunnerScaleSetListenerPod := &corev1.Pod{ TypeMeta: metav1.TypeMeta{ + APIVersion: corev1.SchemeGroupVersion.String(), Kind: "Pod", - APIVersion: "v1", }, ObjectMeta: metav1.ObjectMeta{ Name: autoscalingListener.Name, @@ -489,9 +495,7 @@ func (b *ResourceBuilder) newScaleSetListenerPod( if autoscalingListener.Spec.Template != nil { mergeListenerPodWithTemplate(newRunnerScaleSetListenerPod, autoscalingListener.Spec.Template) } - if b.ResourceCache != nil { - b.ResourceCache.listenerPod.Upsert(autoscalingListener, newRunnerScaleSetListenerPod, podConfig, serviceAccount, role, roleBinding) - } + b.ResourceCache.listenerPod.Upsert(autoscalingListener, newRunnerScaleSetListenerPod, podConfig, serviceAccount, role, roleBinding) return newRunnerScaleSetListenerPod, nil } @@ -652,13 +656,15 @@ func (b *ResourceBuilder) newScaleSetListenerServiceAccount(autoscalingListener Namespace: autoscalingListener.Namespace, }, } - if b.ResourceCache != nil { - if cached, ok := b.ResourceCache.listenerServiceAccount.Get(autoscalingListener, cacheKeyObject); ok { - return cached, nil - } + if cached, ok := b.ResourceCache.listenerServiceAccount.Get(autoscalingListener, cacheKeyObject); ok { + return cached, nil } base := &corev1.ServiceAccount{ + TypeMeta: metav1.TypeMeta{ + APIVersion: corev1.SchemeGroupVersion.String(), + Kind: "ServiceAccount", + }, ObjectMeta: metav1.ObjectMeta{ Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace, @@ -680,9 +686,7 @@ func (b *ResourceBuilder) newScaleSetListenerServiceAccount(autoscalingListener if err := b.setControllerReference(autoscalingListener, base); err != nil { return nil, fmt.Errorf("failed to set controller reference for listener service account: %w", err) } - if b.ResourceCache != nil { - b.ResourceCache.listenerServiceAccount.Upsert(autoscalingListener, base) - } + b.ResourceCache.listenerServiceAccount.Upsert(autoscalingListener, base) return base, nil } @@ -710,10 +714,8 @@ func (b *ResourceBuilder) newScaleSetListenerRole(autoscalingListener *v1alpha1. Namespace: autoscalingListener.Spec.AutoscalingRunnerSetNamespace, }, } - if b.ResourceCache != nil { - if cached, ok := b.ResourceCache.listenerRole.Get(autoscalingListener, cacheKeyObject); ok { - return cached - } + if cached, ok := b.ResourceCache.listenerRole.Get(autoscalingListener, cacheKeyObject); ok { + return cached } labels := b.filterAndMergeLabels(autoscalingListener.Labels, map[string]string{ @@ -730,6 +732,10 @@ func (b *ResourceBuilder) newScaleSetListenerRole(autoscalingListener *v1alpha1. } newRole := &rbacv1.Role{ + TypeMeta: metav1.TypeMeta{ + APIVersion: rbacv1.SchemeGroupVersion.String(), + Kind: "Role", + }, ObjectMeta: metav1.ObjectMeta{ Name: autoscalingListener.Name, Namespace: autoscalingListener.Spec.AutoscalingRunnerSetNamespace, @@ -740,9 +746,7 @@ func (b *ResourceBuilder) newScaleSetListenerRole(autoscalingListener *v1alpha1. } newRole.Annotations[annotationKeyIntegrityHash] = scaleSetRoleIntegrityHash(newRole) - if b.ResourceCache != nil { - b.ResourceCache.listenerRole.Upsert(autoscalingListener, newRole) - } + b.ResourceCache.listenerRole.Upsert(autoscalingListener, newRole) return newRole } @@ -766,10 +770,8 @@ func (b *ResourceBuilder) newScaleSetListenerRoleBinding(autoscalingListener *v1 Namespace: autoscalingListener.Spec.AutoscalingRunnerSetNamespace, }, } - if b.ResourceCache != nil { - if cached, ok := b.ResourceCache.listenerRoleBinding.Get(autoscalingListener, cacheKeyObject, listenerRole, serviceAccount); ok { - return cached - } + if cached, ok := b.ResourceCache.listenerRoleBinding.Get(autoscalingListener, cacheKeyObject, listenerRole, serviceAccount); ok { + return cached } roleRef := rbacv1.RoleRef{ @@ -799,6 +801,10 @@ func (b *ResourceBuilder) newScaleSetListenerRoleBinding(autoscalingListener *v1 } newRoleBinding := &rbacv1.RoleBinding{ + TypeMeta: metav1.TypeMeta{ + APIVersion: rbacv1.SchemeGroupVersion.String(), + Kind: "RoleBinding", + }, ObjectMeta: metav1.ObjectMeta{ Name: autoscalingListener.Name, Namespace: autoscalingListener.Spec.AutoscalingRunnerSetNamespace, @@ -810,9 +816,7 @@ func (b *ResourceBuilder) newScaleSetListenerRoleBinding(autoscalingListener *v1 } newRoleBinding.Annotations[annotationKeyIntegrityHash] = scaleSetListenerRoleBindingIntegrityHash(newRoleBinding) - if b.ResourceCache != nil { - b.ResourceCache.listenerRoleBinding.Upsert(autoscalingListener, newRoleBinding, listenerRole, serviceAccount) - } + b.ResourceCache.listenerRoleBinding.Upsert(autoscalingListener, newRoleBinding, listenerRole, serviceAccount) return newRoleBinding } @@ -843,10 +847,8 @@ func (b *ResourceBuilder) newEphemeralRunnerSet(autoscalingRunnerSet *v1alpha1.A Namespace: autoscalingRunnerSet.Namespace, }, } - if b.ResourceCache != nil { - if cached, ok := b.ResourceCache.ephemeralRunnerSet.Get(autoscalingRunnerSet, cacheKeyObject); ok { - return cached, nil - } + if cached, ok := b.ResourceCache.ephemeralRunnerSet.Get(autoscalingRunnerSet, cacheKeyObject); ok { + return cached, nil } spec := v1alpha1.EphemeralRunnerSetSpec{ @@ -887,7 +889,10 @@ func (b *ResourceBuilder) newEphemeralRunnerSet(autoscalingRunnerSet *v1alpha1.A } newEphemeralRunnerSet := &v1alpha1.EphemeralRunnerSet{ - TypeMeta: metav1.TypeMeta{}, + TypeMeta: metav1.TypeMeta{ + APIVersion: v1alpha1.GroupVersion.String(), + Kind: "EphemeralRunnerSet", + }, ObjectMeta: metav1.ObjectMeta{ Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace, @@ -902,9 +907,7 @@ func (b *ResourceBuilder) newEphemeralRunnerSet(autoscalingRunnerSet *v1alpha1.A if err := b.setControllerReference(autoscalingRunnerSet, newEphemeralRunnerSet); err != nil { return nil, fmt.Errorf("failed to set controller reference for ephemeral runner set: %w", err) } - if b.ResourceCache != nil { - b.ResourceCache.ephemeralRunnerSet.Upsert(autoscalingRunnerSet, newEphemeralRunnerSet) - } + b.ResourceCache.ephemeralRunnerSet.Upsert(autoscalingRunnerSet, newEphemeralRunnerSet) return newEphemeralRunnerSet, nil } diff --git a/controllers/actions.github.com/resourcebuilder_test.go b/controllers/actions.github.com/resourcebuilder_test.go index 4324401e11..6097308d71 100644 --- a/controllers/actions.github.com/resourcebuilder_test.go +++ b/controllers/actions.github.com/resourcebuilder_test.go @@ -102,11 +102,13 @@ func TestMetadataPropagation(t *testing.T) { }, } + cache := NewResourceCache() b := ResourceBuilder{ ExcludeLabelPropagationPrefixes: []string{ "example.com/", "directly.excluded.org/label", }, + ResourceCache: &cache, } ephemeralRunnerSet, err := b.newEphemeralRunnerSet(&autoscalingRunnerSet) require.NoError(t, err) @@ -258,7 +260,8 @@ func TestGitHubURLTrimLabelValues(t *testing.T) { GitHubConfigUrl: fmt.Sprintf("https://github.com/%s/%s", organization, repository), } - var b ResourceBuilder + cache := NewResourceCache() + b := ResourceBuilder{ResourceCache: &cache} ephemeralRunnerSet, err := b.newEphemeralRunnerSet(autoscalingRunnerSet) require.NoError(t, err) assert.Len(t, ephemeralRunnerSet.Labels[LabelKeyGitHubEnterprise], 0) @@ -282,7 +285,8 @@ func TestGitHubURLTrimLabelValues(t *testing.T) { GitHubConfigUrl: fmt.Sprintf("https://github.com/enterprises/%s", enterprise), } - var b ResourceBuilder + cache := NewResourceCache() + b := ResourceBuilder{ResourceCache: &cache} ephemeralRunnerSet, err := b.newEphemeralRunnerSet(autoscalingRunnerSet) require.NoError(t, err) assert.Len(t, ephemeralRunnerSet.Labels[LabelKeyGitHubEnterprise], 63) @@ -323,7 +327,8 @@ func TestOwnershipRelationships(t *testing.T) { } // Initialize ResourceBuilder - b := ResourceBuilder{} + cache := NewResourceCache() + b := ResourceBuilder{ResourceCache: &cache} // Create EphemeralRunnerSet ephemeralRunnerSet, err := b.newEphemeralRunnerSet(&autoscalingRunnerSet) @@ -422,7 +427,8 @@ func TestListenerPodNodeSelector(t *testing.T) { }, } - b := ResourceBuilder{} + cache := NewResourceCache() + b := ResourceBuilder{ResourceCache: &cache} ephemeralRunnerSet, err := b.newEphemeralRunnerSet(&autoscalingRunnerSet) require.NoError(t, err) diff --git a/controllers/actions.github.com/resourcecache.go b/controllers/actions.github.com/resourcecache.go index 1f631be01b..2a4b1c1035 100644 --- a/controllers/actions.github.com/resourcecache.go +++ b/controllers/actions.github.com/resourcecache.go @@ -10,14 +10,20 @@ import ( "github.com/actions/actions-runner-controller/hash" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" ) -var resourceCacheObjectTypes sync.Map +const ( + resourceCacheInitialEntries = 4096 + resourceCacheInitialMainUIDEntries = 4096 + resourceCacheInitialOwnerEntries = 8 + resourceCacheMaxDependencyRefs = 4 +) type ResourceCacheObjectRef struct { - ObjectType string + ObjectType schema.GroupVersionKind Namespace string Name string UID types.UID @@ -33,10 +39,15 @@ type ResourceCacheKey struct { type ResourceCacheValue[T client.Object] struct { MainObject ResourceCacheObjectRef ResourceVersion string - Dependencies []ResourceCacheObjectRef + dependencyKey resourceCacheDependencyKey Object T } +type resourceCacheDependencyKey struct { + count int + refs [resourceCacheMaxDependencyRefs]ResourceCacheObjectRef +} + type ResourceCache struct { autoscalingListener *resourceCacheState[*v1alpha1.AutoscalingListener] ephemeralRunnerSet *resourceCacheState[*v1alpha1.EphemeralRunnerSet] @@ -58,13 +69,15 @@ func NewResourceCache() ResourceCache { } type resourceCacheState[T client.Object] struct { - mu sync.RWMutex - entries map[ResourceCacheKey]ResourceCacheValue[T] + mu sync.RWMutex + entries map[ResourceCacheKey]ResourceCacheValue[T] + entriesByMainUID map[types.UID]map[ResourceCacheKey]struct{} } func newResourceCacheState[T client.Object]() *resourceCacheState[T] { return &resourceCacheState[T]{ - entries: make(map[ResourceCacheKey]ResourceCacheValue[T], 512), + entries: make(map[ResourceCacheKey]ResourceCacheValue[T], resourceCacheInitialEntries), + entriesByMainUID: make(map[types.UID]map[ResourceCacheKey]struct{}, resourceCacheInitialMainUIDEntries), } } @@ -73,17 +86,30 @@ func (s *resourceCacheState[T]) Get( desiredObject T, dependencies ...client.Object, ) (T, bool) { + var zero T + if s == nil || isNilResourceCacheObject(mainObject) || isNilResourceCacheObject(desiredObject) { + return zero, false + } + dependencyKey, ok := newResourceCacheDependencyKey(dependencies...) + if !ok { + return zero, false + } + if mainObject.GetUID() == "" { + return zero, false + } + key := newResourceCacheKey(mainObject, desiredObject) + mainObjectRef := newResourceCacheObjectRef(mainObject) s.mu.RLock() value, ok := s.entries[key] - s.mu.RUnlock() - if !ok || !value.Matches(mainObject, dependencies...) { - var zero T - return zero, false + if ok && value.MainObject == mainObjectRef && value.dependencyKey.Equal(dependencyKey) { + s.mu.RUnlock() + return value.Object, true } + s.mu.RUnlock() - return cloneResourceCacheObject(value.Object), true + return zero, false } func (s *resourceCacheState[T]) Upsert( @@ -91,13 +117,25 @@ func (s *resourceCacheState[T]) Upsert( desiredObject T, dependencies ...client.Object, ) (ResourceCacheValue[T], bool) { + var zero ResourceCacheValue[T] + if s == nil || isNilResourceCacheObject(mainObject) || isNilResourceCacheObject(desiredObject) { + return zero, false + } + dependencyKey, ok := newResourceCacheDependencyKey(dependencies...) + if !ok { + return zero, false + } + if mainObject.GetUID() == "" { + return zero, false + } + key := newResourceCacheKey(mainObject, desiredObject) mainObjectRef := newResourceCacheObjectRef(mainObject) resourceVersion := desiredObject.GetResourceVersion() s.mu.RLock() previous, ok := s.entries[key] - if ok && previous.MainObject == mainObjectRef && previous.ResourceVersion == resourceVersion && previous.dependenciesMatch(dependencies...) { + if ok && previous.MainObject == mainObjectRef && previous.ResourceVersion == resourceVersion && previous.dependencyKey.Equal(dependencyKey) { s.mu.RUnlock() return previous, false } @@ -107,13 +145,18 @@ func (s *resourceCacheState[T]) Upsert( defer s.mu.Unlock() previous, ok = s.entries[key] - if ok && previous.MainObject == mainObjectRef && previous.ResourceVersion == resourceVersion && previous.dependenciesMatch(dependencies...) { + if ok && previous.MainObject == mainObjectRef && previous.ResourceVersion == resourceVersion && previous.dependencyKey.Equal(dependencyKey) { return previous, false } - dependencyRefs := newResourceCacheObjectRefs(dependencies...) - value := newResourceCacheValue(mainObjectRef, resourceVersion, dependencyRefs, cloneResourceCacheObject(desiredObject)) + value := ResourceCacheValue[T]{ + MainObject: mainObjectRef, + ResourceVersion: resourceVersion, + dependencyKey: dependencyKey, + Object: desiredObject, + } s.entries[key] = value + s.indexKeyLocked(key) return value, true } @@ -131,7 +174,7 @@ func (c *ResourceCache) Delete(mainObject client.Object) { } func (s *resourceCacheState[T]) Delete(mainObject client.Object) { - if mainObject == nil { + if s == nil || mainObject == nil { return } @@ -143,19 +186,19 @@ func (s *resourceCacheState[T]) Delete(mainObject client.Object) { s.mu.Lock() defer s.mu.Unlock() - for key := range s.entries { - if key.MainUID == uid { - delete(s.entries, key) - } + for key := range s.entriesByMainUID[uid] { + delete(s.entries, key) } + delete(s.entriesByMainUID, uid) } -func (v ResourceCacheValue[T]) Matches(mainObject client.Object, dependencies ...client.Object) bool { - if v.MainObject != newResourceCacheObjectRef(mainObject) { - return false +func (s *resourceCacheState[T]) indexKeyLocked(key ResourceCacheKey) { + keys, ok := s.entriesByMainUID[key.MainUID] + if !ok { + keys = make(map[ResourceCacheKey]struct{}, resourceCacheInitialOwnerEntries) + s.entriesByMainUID[key.MainUID] = keys } - - return v.dependenciesMatch(dependencies...) + keys[key] = struct{}{} } func newResourceCacheKey(mainObject client.Object, desiredObject client.Object) ResourceCacheKey { @@ -166,43 +209,34 @@ func newResourceCacheKey(mainObject client.Object, desiredObject client.Object) } } -func newResourceCacheValue[T client.Object]( - mainObjectRef ResourceCacheObjectRef, - resourceVersion string, - dependencyRefs []ResourceCacheObjectRef, - object T, -) ResourceCacheValue[T] { - return ResourceCacheValue[T]{ - MainObject: mainObjectRef, - ResourceVersion: resourceVersion, - Dependencies: dependencyRefs, - Object: object, +func newResourceCacheDependencyKey(objects ...client.Object) (resourceCacheDependencyKey, bool) { + if len(objects) > resourceCacheMaxDependencyRefs { + return resourceCacheDependencyKey{}, false } -} -func cloneResourceCacheObject[T client.Object](object T) T { - return object.DeepCopyObject().(T) -} - -func newResourceCacheObjectRefs(objects ...client.Object) []ResourceCacheObjectRef { - refs := make([]ResourceCacheObjectRef, 0, len(objects)) - for _, object := range objects { - refs = append(refs, newResourceCacheObjectRef(object)) + key := resourceCacheDependencyKey{count: len(objects)} + for i, object := range objects { + if isNilResourceCacheObject(object) { + return resourceCacheDependencyKey{}, false + } + key.refs[i] = newResourceCacheObjectRef(object) } - slices.SortFunc(refs, func(a, b ResourceCacheObjectRef) int { + slices.SortFunc(key.refs[:key.count], func(a, b ResourceCacheObjectRef) int { return compareResourceCacheObjectRefs(a, b) }) - return refs + return key, true } -func (v ResourceCacheValue[T]) dependenciesMatch(objects ...client.Object) bool { - if len(v.Dependencies) != len(objects) { +func (k resourceCacheDependencyKey) Equal(other resourceCacheDependencyKey) bool { + if k.count != other.count { + return false + } + if k.count > len(k.refs) || other.count > len(other.refs) { return false } - for _, object := range objects { - ref := newResourceCacheObjectRef(object) - if !slices.Contains(v.Dependencies, ref) { + for i := range k.count { + if k.refs[i] != other.refs[i] { return false } } @@ -212,12 +246,15 @@ func (v ResourceCacheValue[T]) dependenciesMatch(objects ...client.Object) bool func newResourceCacheObjectRef(object client.Object) ResourceCacheObjectRef { resourceVersion := object.GetResourceVersion() + if resourceVersion == "" { + resourceVersion = object.GetAnnotations()[annotationKeyIntegrityHash] + } if resourceVersion == "" { resourceVersion = hash.ComputeTemplateHash(object) } return ResourceCacheObjectRef{ - ObjectType: resourceCacheObjectType(object), + ObjectType: object.GetObjectKind().GroupVersionKind(), Namespace: object.GetNamespace(), Name: resourceCacheObjectName(object), UID: object.GetUID(), @@ -226,7 +263,7 @@ func newResourceCacheObjectRef(object client.Object) ResourceCacheObjectRef { } func compareResourceCacheObjectRefs(a, b ResourceCacheObjectRef) int { - if c := strings.Compare(a.ObjectType, b.ObjectType); c != 0 { + if c := compareGroupVersionKinds(a.ObjectType, b.ObjectType); c != 0 { return c } if c := strings.Compare(a.Namespace, b.Namespace); c != 0 { @@ -241,18 +278,14 @@ func compareResourceCacheObjectRefs(a, b ResourceCacheObjectRef) int { return strings.Compare(a.ResourceVersion, b.ResourceVersion) } -func resourceCacheObjectType(object client.Object) string { - t := reflect.TypeOf(object) - if t.Kind() == reflect.Pointer { - t = t.Elem() +func compareGroupVersionKinds(a, b schema.GroupVersionKind) int { + if c := strings.Compare(a.Group, b.Group); c != 0 { + return c } - if objectType, ok := resourceCacheObjectTypes.Load(t); ok { - return objectType.(string) + if c := strings.Compare(a.Version, b.Version); c != 0 { + return c } - - objectType := t.PkgPath() + "." + t.Name() - actual, _ := resourceCacheObjectTypes.LoadOrStore(t, objectType) - return actual.(string) + return strings.Compare(a.Kind, b.Kind) } func resourceCacheObjectName(object client.Object) string { @@ -261,3 +294,13 @@ func resourceCacheObjectName(object client.Object) string { } return object.GetGenerateName() } + +func isNilResourceCacheObject[T client.Object](object T) bool { + var clientObject client.Object = object + if clientObject == nil { + return true + } + + value := reflect.ValueOf(clientObject) + return value.Kind() == reflect.Pointer && value.IsNil() +} diff --git a/controllers/actions.github.com/resourcecache_test.go b/controllers/actions.github.com/resourcecache_test.go index 8bf576d539..608ed9d95d 100644 --- a/controllers/actions.github.com/resourcecache_test.go +++ b/controllers/actions.github.com/resourcecache_test.go @@ -10,15 +10,35 @@ import ( corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" ) -var benchmarkEphemeralRunnerSetSink *v1alpha1.EphemeralRunnerSet - func newTestResourceCache() *ResourceCache { cache := NewResourceCache() return &cache } +func resourceCacheHasMainObjectEntries(cache *ResourceCache, mainObject client.Object) bool { + return resourceCacheStateHasMainObjectEntries(cache.autoscalingListener, mainObject) || + resourceCacheStateHasMainObjectEntries(cache.ephemeralRunnerSet, mainObject) || + resourceCacheStateHasMainObjectEntries(cache.listenerPod, mainObject) || + resourceCacheStateHasMainObjectEntries(cache.listenerServiceAccount, mainObject) || + resourceCacheStateHasMainObjectEntries(cache.listenerRole, mainObject) || + resourceCacheStateHasMainObjectEntries(cache.listenerRoleBinding, mainObject) +} + +func resourceCacheStateHasMainObjectEntries[T client.Object](state *resourceCacheState[T], mainObject client.Object) bool { + uid := mainObject.GetUID() + if uid == "" { + return false + } + + state.mu.RLock() + defer state.mu.RUnlock() + + return len(state.entriesByMainUID[uid]) > 0 +} + func TestResourceCacheUpsertReplacesByDependencyResourceVersion(t *testing.T) { mainObject := &v1alpha1.AutoscalingListener{ ObjectMeta: metav1.ObjectMeta{ @@ -78,17 +98,14 @@ func TestResourceCacheUpsertReplacesByDependencyResourceVersion(t *testing.T) { configSecret.ResourceVersion = "2" value, replaced = cache.listenerPod.Upsert(mainObject, desiredPod, configSecret, serviceAccount, role) assert.True(t, replaced) - assert.Contains(t, value.Dependencies, ResourceCacheObjectRef{ - ObjectType: resourceCacheObjectType(configSecret), - Namespace: "controller-ns", - Name: "listener-config", - UID: "config-secret-uid", - ResourceVersion: "2", - }) + staleConfigSecret := configSecret.DeepCopy() + staleConfigSecret.ResourceVersion = "1" + _, ok = cache.listenerPod.Get(mainObject, desiredPod, staleConfigSecret, serviceAccount, role) + assert.False(t, ok) + _, ok = cache.listenerPod.Get(mainObject, desiredPod, configSecret, serviceAccount, role) + assert.True(t, ok) - desiredPod.Labels["mutated"] = "after-cache" - cachedPod := value.Object - assert.NotContains(t, cachedPod.Labels, "mutated") + assert.Same(t, desiredPod, value.Object) } func TestResourceCacheDeleteRemovesMainObjectEntries(t *testing.T) { @@ -132,6 +149,38 @@ func TestResourceCacheDeletePanicsWithNilCache(t *testing.T) { }) } +func TestResourceCacheIgnoresInvalidInputs(t *testing.T) { + cache := NewResourceCache() + desiredPod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "listener", Namespace: "controller-ns"}} + mainObjectWithoutUID := &v1alpha1.AutoscalingListener{ObjectMeta: metav1.ObjectMeta{Name: "listener", Namespace: "controller-ns"}} + mainObject := mainObjectWithoutUID.DeepCopy() + mainObject.UID = "listener-uid" + + _, replaced := cache.listenerPod.Upsert(mainObjectWithoutUID, desiredPod) + assert.False(t, replaced) + _, ok := cache.listenerPod.Get(mainObjectWithoutUID, desiredPod) + assert.False(t, ok) + + var nilDependency *corev1.Secret + assert.NotPanics(t, func() { + _, replaced = cache.listenerPod.Upsert(mainObject, desiredPod, nilDependency) + assert.False(t, replaced) + _, ok = cache.listenerPod.Get(mainObject, desiredPod, nilDependency) + assert.False(t, ok) + }) + + tooManyDependencies := make([]client.Object, resourceCacheMaxDependencyRefs+1) + for i := range tooManyDependencies { + tooManyDependencies[i] = &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("dependency-%d", i), Namespace: "controller-ns"}} + } + assert.NotPanics(t, func() { + _, replaced = cache.listenerPod.Upsert(mainObject, desiredPod, tooManyDependencies...) + assert.False(t, replaced) + _, ok = cache.listenerPod.Get(mainObject, desiredPod, tooManyDependencies...) + assert.False(t, ok) + }) +} + func TestResourceBuilderCachesListenerPodDependencies(t *testing.T) { listener := &v1alpha1.AutoscalingListener{ ObjectMeta: metav1.ObjectMeta{ @@ -231,128 +280,13 @@ func TestResourceBuilderCachesEphemeralRunnerSet(t *testing.T) { cachedRunnerSet, ok := b.ResourceCache.ephemeralRunnerSet.Get(&autoscalingRunnerSet, runnerSet) require.True(t, ok) assert.Equal(t, runnerSet.Spec, cachedRunnerSet.Spec) - - runnerSet.Labels["mutated"] = "after-cache" - assert.NotContains(t, cachedRunnerSet.Labels, "mutated") + assert.Same(t, runnerSet, cachedRunnerSet) fromBuilder, err := b.newEphemeralRunnerSet(&autoscalingRunnerSet) require.NoError(t, err) - assert.NotContains(t, fromBuilder.Labels, "mutated") + assert.Same(t, runnerSet, fromBuilder) autoscalingRunnerSet.Annotations[runnerScaleSetIDAnnotationKey] = "2" _, ok = b.ResourceCache.ephemeralRunnerSet.Get(&autoscalingRunnerSet, runnerSet) assert.False(t, ok) } - -func BenchmarkNewEphemeralRunnerSetResourceCache(b *testing.B) { - autoscalingRunnerSet := newBenchmarkAutoscalingRunnerSet() - - b.Run("no_cache", func(b *testing.B) { - builder := ResourceBuilder{} - b.ReportAllocs() - b.ResetTimer() - - for i := 0; i < b.N; i++ { - runnerSet, err := builder.newEphemeralRunnerSet(autoscalingRunnerSet) - if err != nil { - b.Fatal(err) - } - benchmarkEphemeralRunnerSetSink = runnerSet - } - }) - - b.Run("cache_hit", func(b *testing.B) { - cache := NewResourceCache() - builder := ResourceBuilder{ResourceCache: &cache} - if _, err := builder.newEphemeralRunnerSet(autoscalingRunnerSet); err != nil { - b.Fatal(err) - } - - b.ReportAllocs() - b.ResetTimer() - - for i := 0; i < b.N; i++ { - runnerSet, err := builder.newEphemeralRunnerSet(autoscalingRunnerSet) - if err != nil { - b.Fatal(err) - } - benchmarkEphemeralRunnerSetSink = runnerSet - } - }) - - b.Run("cache_miss", func(b *testing.B) { - cache := NewResourceCache() - builder := ResourceBuilder{ResourceCache: &cache} - autoscalingRunnerSet := autoscalingRunnerSet.DeepCopy() - - b.ReportAllocs() - b.ResetTimer() - - for i := 0; i < b.N; i++ { - autoscalingRunnerSet.ResourceVersion = fmt.Sprint(i) - runnerSet, err := builder.newEphemeralRunnerSet(autoscalingRunnerSet) - if err != nil { - b.Fatal(err) - } - benchmarkEphemeralRunnerSetSink = runnerSet - } - }) -} - -func newBenchmarkAutoscalingRunnerSet() *v1alpha1.AutoscalingRunnerSet { - return &v1alpha1.AutoscalingRunnerSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "benchmark-scale-set", - Namespace: "benchmark-namespace", - UID: "benchmark-scale-set-uid", - ResourceVersion: "1", - Labels: map[string]string{ - LabelKeyKubernetesVersion: "0.12.0", - "example.com/label-1": "value-1", - "example.com/label-2": "value-2", - }, - Annotations: map[string]string{ - runnerScaleSetIDAnnotationKey: "123", - AnnotationKeyGitHubRunnerGroupName: "benchmark-runner-group", - AnnotationKeyGitHubRunnerScaleSetName: "benchmark-scale-set", - }, - }, - Spec: v1alpha1.AutoscalingRunnerSetSpec{ - GitHubConfigUrl: "https://github.com/actions/actions-runner-controller", - EphemeralRunnerSetMetadata: &v1alpha1.ResourceMeta{ - Labels: map[string]string{ - "example.com/runner-set-label": "runner-set-value", - }, - Annotations: map[string]string{ - "example.com/runner-set-annotation": "runner-set-value", - }, - }, - EphemeralRunnerMetadata: &v1alpha1.ResourceMeta{ - Labels: map[string]string{ - "example.com/runner-label": "runner-value", - }, - Annotations: map[string]string{ - "example.com/runner-annotation": "runner-value", - }, - }, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "example.com/template-label": "template-value", - }, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: v1alpha1.EphemeralRunnerContainerName, - Image: "ghcr.io/actions/actions-runner:latest", - Env: []corev1.EnvVar{ - {Name: "ACTIONS_RUNNER_REQUIRE_JOB_CONTAINER", Value: "false"}, - }, - }, - }, - }, - }, - }, - } -} From 36d8aae9a6c9b55078711db04f78860a725d04d0 Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Thu, 16 Jul 2026 18:32:53 +0200 Subject: [PATCH 23/31] Remove fingerprint from annotation --- .github/workflows/arc-publish-chart.yaml | 2 +- .github/workflows/arc-validate-chart.yaml | 2 +- .github/workflows/gha-publish-chart.yaml | 16 +- .github/workflows/gha-validate-chart.yaml | 4 +- .github/workflows/global-publish-canary.yaml | 8 +- .github/workflows/go.yaml | 2 +- .../v1alpha1/autoscalingrunnerset_types.go | 4 + .../v1alpha1/ephemeralrunnerset_types.go | 10 + ...ions.github.com_autoscalingrunnersets.yaml | 6 + ...ctions.github.com_ephemeralrunnersets.yaml | 14 + ...ions.github.com_autoscalingrunnersets.yaml | 6 + ...ctions.github.com_ephemeralrunnersets.yaml | 14 + ...ions.github.com_autoscalingrunnersets.yaml | 6 + ...ctions.github.com_ephemeralrunnersets.yaml | 14 + .../autoscalinglistener_controller.go | 20 +- .../autoscalinglistener_controller_test.go | 194 ++++++--- .../autoscalingrunnerset_controller.go | 53 +-- .../autoscalingrunnerset_controller_test.go | 17 +- .../ephemeralrunner_controller.go | 44 +- .../ephemeralrunnerset_controller.go | 110 ++++- .../ephemeralrunnerset_controller_test.go | 378 ++++++++++++++++++ controllers/actions.github.com/helpers.go | 65 +++ .../actions.github.com/helpers_test.go | 197 +++++++++ .../actions.github.com/resourcebuilder.go | 209 ++-------- .../resourcebuilder_test.go | 30 -- .../actions.github.com/resourcecache.go | 61 ++- .../actions.github.com/resourcecache_test.go | 259 ++++++++++-- 27 files changed, 1359 insertions(+), 386 deletions(-) create mode 100644 controllers/actions.github.com/helpers.go diff --git a/.github/workflows/arc-publish-chart.yaml b/.github/workflows/arc-publish-chart.yaml index 9e4fde5ada..5d1983a246 100644 --- a/.github/workflows/arc-publish-chart.yaml +++ b/.github/workflows/arc-publish-chart.yaml @@ -45,7 +45,7 @@ jobs: fetch-depth: 0 - name: Set up Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 with: version: ${{ env.HELM_VERSION }} diff --git a/.github/workflows/arc-validate-chart.yaml b/.github/workflows/arc-validate-chart.yaml index f905306c5a..e9ea3ba0dc 100644 --- a/.github/workflows/arc-validate-chart.yaml +++ b/.github/workflows/arc-validate-chart.yaml @@ -45,7 +45,7 @@ jobs: fetch-depth: 0 - name: Set up Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 with: version: ${{ env.HELM_VERSION }} diff --git a/.github/workflows/gha-publish-chart.yaml b/.github/workflows/gha-publish-chart.yaml index af42962ba1..8bcb407670 100644 --- a/.github/workflows/gha-publish-chart.yaml +++ b/.github/workflows/gha-publish-chart.yaml @@ -82,10 +82,10 @@ jobs: echo "repository_owner=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - name: Set up QEMU - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c with: # Pinning v0.9.1 for Buildx and BuildKit v0.10.6 # BuildKit v0.11 which has a bug causing intermittent @@ -94,14 +94,14 @@ jobs: driver-opts: image=moby/buildkit:v0.10.6 - name: Login to GitHub Container Registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build & push controller image - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a with: context: . file: Dockerfile @@ -149,7 +149,7 @@ jobs: echo "repository_owner=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - name: Set up Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 with: version: ${{ env.HELM_VERSION }} @@ -197,7 +197,7 @@ jobs: echo "repository_owner=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - name: Set up Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 with: version: ${{ env.HELM_VERSION }} @@ -244,7 +244,7 @@ jobs: echo "repository_owner=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - name: Set up Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 with: version: ${{ env.HELM_VERSION }} @@ -293,7 +293,7 @@ jobs: echo "repository_owner=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - name: Set up Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 with: version: ${{ env.HELM_VERSION }} diff --git a/.github/workflows/gha-validate-chart.yaml b/.github/workflows/gha-validate-chart.yaml index 949432ce96..8e6ed541c4 100644 --- a/.github/workflows/gha-validate-chart.yaml +++ b/.github/workflows/gha-validate-chart.yaml @@ -41,7 +41,7 @@ jobs: fetch-depth: 0 - name: Set up Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 with: version: ${{ env.HELM_VERSION }} @@ -79,7 +79,7 @@ jobs: fetch-depth: 0 - name: Set up Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 with: version: ${{ env.HELM_VERSION }} diff --git a/.github/workflows/global-publish-canary.yaml b/.github/workflows/global-publish-canary.yaml index 7adce96677..0a1775650a 100644 --- a/.github/workflows/global-publish-canary.yaml +++ b/.github/workflows/global-publish-canary.yaml @@ -93,7 +93,7 @@ jobs: uses: actions/checkout@v7 - name: Login to GitHub Container Registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 with: registry: ghcr.io username: ${{ github.actor }} @@ -110,16 +110,16 @@ jobs: echo "repository_owner=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - name: Set up QEMU - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c with: version: latest # Unstable builds - run at your own risk - name: Build and Push - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a with: context: . file: ./Dockerfile diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index 6a30459fd6..402b6c2a82 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -48,7 +48,7 @@ jobs: go-version-file: "go.mod" cache: false - name: golangci-lint - uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a with: only-new-issues: true version: v2.11.2 diff --git a/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go b/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go index dd1bce7fa1..b383c2c49d 100644 --- a/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go +++ b/apis/actions.github.com/v1alpha1/autoscalingrunnerset_types.go @@ -319,6 +319,10 @@ type HistogramMetric struct { type AutoscalingRunnerSetStatus struct { // +optional Phase AutoscalingRunnerSetPhase `json:"phase"` + // ObservedGeneration tracks the metadata.generation of this ARS at observation time, + // enabling detection of Pending phase when generation differs. Unset defaults to 0. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` } type AutoscalingRunnerSetPhase string diff --git a/apis/actions.github.com/v1alpha1/ephemeralrunnerset_types.go b/apis/actions.github.com/v1alpha1/ephemeralrunnerset_types.go index 641ec02f88..bfa02d1dbf 100644 --- a/apis/actions.github.com/v1alpha1/ephemeralrunnerset_types.go +++ b/apis/actions.github.com/v1alpha1/ephemeralrunnerset_types.go @@ -36,12 +36,22 @@ type EphemeralRunnerSetSpec struct { // but does not apply to existing ephemeral runners. // +optional EphemeralRunnerMetadata *ResourceMeta `json:"ephemeralRunnerMetadata,omitempty"` + // ActionableRevision is a restart-safe applied marker that increments whenever + // Spec.EphemeralRunnerSpec changes, enabling detection of spec updates. + // Unset defaults to 0. + // +optional + ActionableRevision int64 `json:"actionableRevision,omitempty"` } // EphemeralRunnerSetStatus defines the observed state of EphemeralRunnerSet type EphemeralRunnerSetStatus struct { // +optional Phase EphemeralRunnerSetPhase `json:"phase"` + // AppliedActionableRevision is a restart-safe applied marker tracking the last successfully + // applied ActionableRevision value. Advances only after spec cleanup succeeds. + // Unset defaults to 0. + // +optional + AppliedActionableRevision int64 `json:"appliedActionableRevision,omitempty"` } // EphemeralRunnerSetPhase is the phase of the ephemeral runner set resource diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml index a2dfd46d9f..27c74aabee 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_autoscalingrunnersets.yaml @@ -16541,6 +16541,12 @@ spec: status: description: AutoscalingRunnerSetStatus defines the observed state of AutoscalingRunnerSet properties: + observedGeneration: + description: |- + ObservedGeneration tracks the metadata.generation of this ARS at observation time, + enabling detection of Pending phase when generation differs. Unset defaults to 0. + format: int64 + type: integer phase: type: string type: object diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml index a6f9758bfa..cad0714011 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml @@ -46,6 +46,13 @@ spec: spec: description: EphemeralRunnerSetSpec defines the desired state of EphemeralRunnerSet properties: + actionableRevision: + description: |- + ActionableRevision is a restart-safe applied marker that increments whenever + Spec.EphemeralRunnerSpec changes, enabling detection of spec updates. + Unset defaults to 0. + format: int64 + type: integer ephemeralRunnerMetadata: description: |- EphemeralRunnerMetadata is the metadata to be applied to all ephemeral runners created by this set. @@ -8295,6 +8302,13 @@ spec: status: description: EphemeralRunnerSetStatus defines the observed state of EphemeralRunnerSet properties: + appliedActionableRevision: + description: |- + AppliedActionableRevision is a restart-safe applied marker tracking the last successfully + applied ActionableRevision value. Advances only after spec cleanup succeeds. + Unset defaults to 0. + format: int64 + type: integer phase: description: EphemeralRunnerSetPhase is the phase of the ephemeral runner set resource type: string diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml index a2dfd46d9f..27c74aabee 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_autoscalingrunnersets.yaml @@ -16541,6 +16541,12 @@ spec: status: description: AutoscalingRunnerSetStatus defines the observed state of AutoscalingRunnerSet properties: + observedGeneration: + description: |- + ObservedGeneration tracks the metadata.generation of this ARS at observation time, + enabling detection of Pending phase when generation differs. Unset defaults to 0. + format: int64 + type: integer phase: type: string type: object diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml index a6f9758bfa..cad0714011 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml @@ -46,6 +46,13 @@ spec: spec: description: EphemeralRunnerSetSpec defines the desired state of EphemeralRunnerSet properties: + actionableRevision: + description: |- + ActionableRevision is a restart-safe applied marker that increments whenever + Spec.EphemeralRunnerSpec changes, enabling detection of spec updates. + Unset defaults to 0. + format: int64 + type: integer ephemeralRunnerMetadata: description: |- EphemeralRunnerMetadata is the metadata to be applied to all ephemeral runners created by this set. @@ -8295,6 +8302,13 @@ spec: status: description: EphemeralRunnerSetStatus defines the observed state of EphemeralRunnerSet properties: + appliedActionableRevision: + description: |- + AppliedActionableRevision is a restart-safe applied marker tracking the last successfully + applied ActionableRevision value. Advances only after spec cleanup succeeds. + Unset defaults to 0. + format: int64 + type: integer phase: description: EphemeralRunnerSetPhase is the phase of the ephemeral runner set resource type: string diff --git a/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml b/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml index a2dfd46d9f..27c74aabee 100644 --- a/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml +++ b/config/crd/bases/actions.github.com_autoscalingrunnersets.yaml @@ -16541,6 +16541,12 @@ spec: status: description: AutoscalingRunnerSetStatus defines the observed state of AutoscalingRunnerSet properties: + observedGeneration: + description: |- + ObservedGeneration tracks the metadata.generation of this ARS at observation time, + enabling detection of Pending phase when generation differs. Unset defaults to 0. + format: int64 + type: integer phase: type: string type: object diff --git a/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml b/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml index a6f9758bfa..cad0714011 100644 --- a/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml +++ b/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml @@ -46,6 +46,13 @@ spec: spec: description: EphemeralRunnerSetSpec defines the desired state of EphemeralRunnerSet properties: + actionableRevision: + description: |- + ActionableRevision is a restart-safe applied marker that increments whenever + Spec.EphemeralRunnerSpec changes, enabling detection of spec updates. + Unset defaults to 0. + format: int64 + type: integer ephemeralRunnerMetadata: description: |- EphemeralRunnerMetadata is the metadata to be applied to all ephemeral runners created by this set. @@ -8295,6 +8302,13 @@ spec: status: description: EphemeralRunnerSetStatus defines the observed state of EphemeralRunnerSet properties: + appliedActionableRevision: + description: |- + AppliedActionableRevision is a restart-safe applied marker tracking the last successfully + applied ActionableRevision value. Advances only after spec cleanup succeeds. + Unset defaults to 0. + format: int64 + type: integer phase: description: EphemeralRunnerSetPhase is the phase of the ephemeral runner set resource type: string diff --git a/controllers/actions.github.com/autoscalinglistener_controller.go b/controllers/actions.github.com/autoscalinglistener_controller.go index 38e8d64fdb..d473b843bf 100644 --- a/controllers/actions.github.com/autoscalinglistener_controller.go +++ b/controllers/actions.github.com/autoscalinglistener_controller.go @@ -209,7 +209,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. desiredRole := r.newScaleSetListenerRole(&autoscalingListener) desiredLabels := r.filterAndMergeLabels(listenerRole.Labels, desiredRole.Labels) labelsModified := !maps.Equal(listenerRole.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(listenerRole.Annotations, desiredRole.Annotations) + desiredAnnotations := desiredRole.Annotations annotationsModified := !maps.Equal(listenerRole.Annotations, desiredAnnotations) rulesModified := !reflect.DeepEqual(listenerRole.Rules, desiredRole.Rules) if labelsModified || annotationsModified || rulesModified { @@ -251,7 +251,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. ) desiredLabels := r.filterAndMergeLabels(listenerRoleBinding.Labels, desiredRoleBinding.Labels) labelsModified := !maps.Equal(listenerRoleBinding.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(listenerRoleBinding.Annotations, desiredRoleBinding.Annotations) + desiredAnnotations := desiredRoleBinding.Annotations annotationsModified := !maps.Equal(listenerRoleBinding.Annotations, desiredAnnotations) if labelsModified || annotationsModified { updatedRoleBinding := listenerRoleBinding.DeepCopy() @@ -306,7 +306,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. } desiredLabels := r.filterAndMergeLabels(proxySecret.Labels, desiredListenerProxy.Labels) labelsModified := !maps.Equal(proxySecret.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(proxySecret.Annotations, desiredListenerProxy.Annotations) + desiredAnnotations := desiredListenerProxy.Annotations annotationsModified := !maps.Equal(proxySecret.Annotations, desiredAnnotations) if labelsModified || annotationsModified { updatedProxySecret := proxySecret.DeepCopy() @@ -392,7 +392,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. } desiredLabels := r.filterAndMergeLabels(listenerConfigSecret.Labels, desiredSecret.Labels) labelsModified := !maps.Equal(listenerConfigSecret.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(listenerConfigSecret.Annotations, desiredSecret.Annotations) + desiredAnnotations := desiredSecret.Annotations annotationsModified := !maps.Equal(listenerConfigSecret.Annotations, desiredAnnotations) if labelsModified || annotationsModified { @@ -463,7 +463,12 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, err } - shouldReCreate := desiredPod.Annotations[annotationKeyIntegrityHash] != listenerPod.Annotations[annotationKeyIntegrityHash] + desiredLabels := r.filterAndMergeLabels(listenerPod.Labels, desiredPod.Labels) + labelsModified := !maps.Equal(listenerPod.Labels, desiredLabels) + desiredAnnotations := r.mergeAnnotations(listenerPod.Annotations, desiredPod.Annotations) + annotationsModified := !maps.Equal(listenerPod.Annotations, desiredAnnotations) + + shouldReCreate := listenerPodSpecRequiresRecreation(&listenerPod, desiredPod) if shouldReCreate { log.Info("Listener pod dependency changed, recreating listener pod") if err := r.deleteListenerPod(ctx, &autoscalingListener, &listenerPod, log); err != nil { @@ -474,11 +479,6 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, nil } - desiredLabels := r.filterAndMergeLabels(listenerPod.Labels, desiredPod.Labels) - labelsModified := !maps.Equal(listenerPod.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(listenerPod.Annotations, desiredPod.Annotations) - annotationsModified := !maps.Equal(listenerPod.Annotations, desiredAnnotations) - if labelsModified || annotationsModified { updatedPod := listenerPod.DeepCopy() if labelsModified { diff --git a/controllers/actions.github.com/autoscalinglistener_controller_test.go b/controllers/actions.github.com/autoscalinglistener_controller_test.go index 67f7ecf5e7..9d32870323 100644 --- a/controllers/actions.github.com/autoscalinglistener_controller_test.go +++ b/controllers/actions.github.com/autoscalinglistener_controller_test.go @@ -401,6 +401,8 @@ var _ = Describe("Test AutoScalingListener controller", func() { autoscalingListenerTestInterval, ).Should(BeEquivalentTo(autoscalingListener.Name), "Pod should be created") + oldPodUID := string(pod.UID) + // Update the AutoScalingListener updated := autoscalingListener.DeepCopy() updated.Spec.EphemeralRunnerSetName = "test-ers-updated" @@ -421,6 +423,20 @@ var _ = Describe("Test AutoScalingListener controller", func() { autoscalingListenerTestTimeout, autoscalingListenerTestInterval, ).Should(BeEquivalentTo(rulesForListenerRole([]string{updated.Spec.EphemeralRunnerSetName})), "Role should be updated") + + Eventually( + func() (string, error) { + pod := new(corev1.Pod) + err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, pod) + if err != nil { + return "", err + } + + return string(pod.UID), nil + }, + autoscalingListenerTestTimeout, + autoscalingListenerTestInterval, + ).Should(BeEquivalentTo(oldPodUID), "Pod should not be re-created when only listener role rules change") }) It("propagates updated listener metadata to owned resources", func() { @@ -431,37 +447,25 @@ var _ = Describe("Test AutoScalingListener controller", func() { err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, serviceAccount) g.Expect(err).NotTo(HaveOccurred(), "failed to get ServiceAccount") g.Expect(serviceAccount.Labels["arc.test/listener-label"]).To(Equal(expected)) - g.Expect(serviceAccount.Annotations["arc.test/service-account-annotation"]).To(Equal(expected)) - if expected == "updated" { - g.Expect(serviceAccount.Annotations["arc.test/new-service-account-annotation"]).To(Equal("added")) - } + g.Expect(serviceAccount.Annotations["arc.test/service-account-annotation"]).To(Equal("initial")) role := new(rbacv1.Role) err = k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Spec.AutoscalingRunnerSetNamespace}, role) g.Expect(err).NotTo(HaveOccurred(), "failed to get Role") g.Expect(role.Labels["arc.test/listener-label"]).To(Equal(expected)) - g.Expect(role.Annotations["arc.test/role-annotation"]).To(Equal(expected)) - if expected == "updated" { - g.Expect(role.Annotations["arc.test/new-role-annotation"]).To(Equal("added")) - } + g.Expect(role.Annotations["arc.test/role-annotation"]).To(Equal("initial")) roleBinding := new(rbacv1.RoleBinding) err = k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Spec.AutoscalingRunnerSetNamespace}, roleBinding) g.Expect(err).NotTo(HaveOccurred(), "failed to get RoleBinding") g.Expect(roleBinding.Labels["arc.test/listener-label"]).To(Equal(expected)) - g.Expect(roleBinding.Annotations["arc.test/role-binding-annotation"]).To(Equal(expected)) - if expected == "updated" { - g.Expect(roleBinding.Annotations["arc.test/new-role-binding-annotation"]).To(Equal("added")) - } + g.Expect(roleBinding.Annotations["arc.test/role-binding-annotation"]).To(Equal("initial")) secret := new(corev1.Secret) err = k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerConfigName(autoscalingListener), Namespace: autoscalingListener.Namespace}, secret) g.Expect(err).NotTo(HaveOccurred(), "failed to get config Secret") - g.Expect(secret.Labels["arc.test/config-secret-label"]).To(Equal(expected)) - g.Expect(secret.Annotations["arc.test/config-secret-annotation"]).To(Equal(expected)) - if expected == "updated" { - g.Expect(secret.Annotations["arc.test/new-config-secret-annotation"]).To(Equal("added")) - } + g.Expect(secret.Labels["arc.test/config-secret-label"]).To(Equal("initial")) + g.Expect(secret.Annotations["arc.test/config-secret-annotation"]).To(Equal("initial")) pod := new(corev1.Pod) err = k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, pod) @@ -475,45 +479,84 @@ var _ = Describe("Test AutoScalingListener controller", func() { assertPropagatedMetadata("initial") + pod := new(corev1.Pod) + Eventually( + func() (string, error) { + err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, pod) + if err != nil { + return "", err + } + + return string(pod.UID), nil + }, + autoscalingListenerTestTimeout, + autoscalingListenerTestInterval, + ).ShouldNot(BeEmpty(), "Pod should be created") + + oldPodUID := string(pod.UID) + + serviceAccount := new(corev1.ServiceAccount) + err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, serviceAccount) + Expect(err).NotTo(HaveOccurred(), "failed to get ServiceAccount") + + updatedServiceAccount := serviceAccount.DeepCopy() + if updatedServiceAccount.Annotations == nil { + updatedServiceAccount.Annotations = make(map[string]string) + } + updatedServiceAccount.Annotations["arc.test/third-party-service-account-annotation"] = "preserved" + err = k8sClient.Patch(ctx, updatedServiceAccount, client.MergeFrom(serviceAccount)) + Expect(err).NotTo(HaveOccurred(), "failed to patch third-party ServiceAccount annotation") + + updatedPod := pod.DeepCopy() + if updatedPod.Annotations == nil { + updatedPod.Annotations = make(map[string]string) + } + updatedPod.Annotations["arc.test/third-party-pod-annotation"] = "preserved" + err = k8sClient.Patch(ctx, updatedPod, client.MergeFrom(pod)) + Expect(err).NotTo(HaveOccurred(), "failed to patch third-party Pod annotation") + current := new(v1alpha1.AutoscalingListener) - err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, current) + err = k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, current) Expect(err).NotTo(HaveOccurred(), "failed to get AutoScalingListener") updated := current.DeepCopy() updated.Labels = map[string]string{ "arc.test/listener-label": "updated", } - updated.Spec.ServiceAccountMetadata = &v1alpha1.ResourceMeta{ - Annotations: map[string]string{ - "arc.test/service-account-annotation": "updated", - "arc.test/new-service-account-annotation": "added", - }, - } - updated.Spec.RoleMetadata = &v1alpha1.ResourceMeta{ - Annotations: map[string]string{ - "arc.test/role-annotation": "updated", - "arc.test/new-role-annotation": "added", - }, - } - updated.Spec.RoleBindingMetadata = &v1alpha1.ResourceMeta{ - Annotations: map[string]string{ - "arc.test/role-binding-annotation": "updated", - "arc.test/new-role-binding-annotation": "added", - }, - } - updated.Spec.ConfigSecretMetadata = &v1alpha1.ResourceMeta{ - Labels: map[string]string{ - "arc.test/config-secret-label": "updated", - }, - Annotations: map[string]string{ - "arc.test/config-secret-annotation": "updated", - "arc.test/new-config-secret-annotation": "added", - }, - } err = k8sClient.Patch(ctx, updated, client.MergeFrom(current)) - Expect(err).NotTo(HaveOccurred(), "failed to patch AutoScalingListener metadata") + Expect(err).NotTo(HaveOccurred(), "failed to patch AutoScalingListener labels") assertPropagatedMetadata("updated") + + Eventually( + func(g Gomega) { + serviceAccount := new(corev1.ServiceAccount) + err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, serviceAccount) + g.Expect(err).NotTo(HaveOccurred(), "failed to get ServiceAccount") + g.Expect(serviceAccount.Annotations).To(HaveKeyWithValue("arc.test/third-party-service-account-annotation", "preserved")) + + pod := new(corev1.Pod) + err = k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, pod) + g.Expect(err).NotTo(HaveOccurred(), "failed to get Pod") + g.Expect(pod.Annotations).To(HaveKeyWithValue("arc.test/third-party-pod-annotation", "preserved")) + }, + autoscalingListenerTestTimeout, + autoscalingListenerTestInterval, + ).Should(Succeed()) + + Eventually( + func() (string, error) { + pod := new(corev1.Pod) + err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, pod) + if err != nil { + return "", err + } + + return string(pod.UID), nil + }, + autoscalingListenerTestTimeout, + autoscalingListenerTestInterval, + ).Should(BeEquivalentTo(oldPodUID), "Pod should be patched, not re-created, for metadata-only updates") }) It("It should re-create pod but persist config secret whenever listener container is terminated", func() { @@ -588,6 +631,7 @@ var _ = Describe("Test AutoScalingListener controller", func() { autoscalingListenerTestInterval, ).Should(BeEquivalentTo(oldSecretUID), "Config secret should persist (not be re-created)") }) + }) }) @@ -1097,6 +1141,64 @@ var _ = Describe("Test AutoScalingListener controller with proxy", func() { autoscalingListenerTestInterval, ).Should(Succeed(), "failed to delete secret with proxy details") }) + + It("should re-create listener pod when proxy dependency changes", func() { + proxy := &v1alpha1.ProxyConfig{ + HTTP: &v1alpha1.ProxyServerConfig{ + Url: "http://localhost:8080", + }, + NoProxy: []string{"example.com"}, + } + + createRunnerSetAndListener(proxy) + + pod := new(corev1.Pod) + Eventually( + func() (string, error) { + err := k8sClient.Get( + ctx, + client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, + pod, + ) + if err != nil { + return "", err + } + + return string(pod.UID), nil + }, + autoscalingListenerTestTimeout, + autoscalingListenerTestInterval, + ).ShouldNot(BeEmpty(), "Pod should be created") + + oldPodUID := string(pod.UID) + + current := new(v1alpha1.AutoscalingListener) + err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, current) + Expect(err).NotTo(HaveOccurred(), "failed to get AutoScalingListener") + + updated := current.DeepCopy() + updated.Spec.Proxy.NoProxy = []string{"example.com", "example.org"} + err = k8sClient.Patch(ctx, updated, client.MergeFrom(current)) + Expect(err).NotTo(HaveOccurred(), "failed to patch AutoScalingListener proxy") + + Eventually( + func() (string, error) { + pod := new(corev1.Pod) + err := k8sClient.Get( + ctx, + client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, + pod, + ) + if err != nil { + return "", err + } + + return string(pod.UID), nil + }, + autoscalingListenerTestTimeout, + autoscalingListenerTestInterval, + ).Should(BeEquivalentTo(oldPodUID), "Pod should not be re-created when proxy metadata updates do not change pod spec") + }) }) var _ = Describe("Test AutoScalingListener controller with template modification", func() { diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller.go b/controllers/actions.github.com/autoscalingrunnerset_controller.go index 08c9ebc960..0cbf1b49f8 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller.go @@ -142,27 +142,17 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl return ctrl.Result{}, nil } - // Something has changed, we need to re-apply the pending phase and change hash annotation to trigger the update of runner scale set and listener. - if targetHash := autoscalingRunnerSet.Hash(); autoscalingRunnerSet.Annotations[annotationKeyIntegrityHash] != targetHash { - // TODO: apply the version label - original := autoscalingRunnerSet.DeepCopy() - if autoscalingRunnerSet.Annotations == nil { - autoscalingRunnerSet.Annotations = map[string]string{} - } - autoscalingRunnerSet.Annotations[annotationKeyIntegrityHash] = targetHash - if err := r.Patch(ctx, &autoscalingRunnerSet, client.MergeFrom(original)); err != nil { - log.Error(err, "Failed to update autoscaling runner set with new change hash and pending phase") - return ctrl.Result{}, err - } - - original = autoscalingRunnerSet.DeepCopy() - autoscalingRunnerSet.Status.Phase = v1alpha1.AutoscalingRunnerSetPhasePending - if err := r.Status().Patch(ctx, &autoscalingRunnerSet, client.MergeFrom(original)); err != nil { + if autoscalingRunnerSet.Generation > autoscalingRunnerSet.Status.ObservedGeneration { + if err := r.updateStatus( + ctx, + &autoscalingRunnerSet, + v1alpha1.AutoscalingRunnerSetPhasePending, + autoscalingRunnerSet.Status.ObservedGeneration, + log, + ); err != nil { log.Error(err, "Failed to update autoscaling runner set status with pending phase") return ctrl.Result{}, err } - - return ctrl.Result{}, nil } outdated := autoscalingRunnerSet.Status.Phase == v1alpha1.AutoscalingRunnerSetPhaseOutdated @@ -292,12 +282,13 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl return ctrl.Result{}, nil } - if ephemeralRunnerSet.Annotations[annotationKeyIntegrityHash] != desired.Annotations[annotationKeyIntegrityHash] { + if ephemeralRunnerSetActionableSpecChanged(&ephemeralRunnerSet, desired) { original := ephemeralRunnerSet.DeepCopy() ephemeralRunnerSet.Spec.EphemeralRunnerMetadata = desired.Spec.EphemeralRunnerMetadata ephemeralRunnerSet.Spec.EphemeralRunnerSpec = desired.Spec.EphemeralRunnerSpec + ephemeralRunnerSet.Spec.ActionableRevision = nextActionableRevision(&ephemeralRunnerSet) ephemeralRunnerSet.Labels = r.filterAndMergeLabels(ephemeralRunnerSet.Labels, desired.Labels) - ephemeralRunnerSet.Annotations = r.mergeAnnotations(ephemeralRunnerSet.Annotations, desired.Annotations) + ephemeralRunnerSet.Annotations = desired.Annotations log.Info("Updating ephemeral runner set spec to match the desired spec") if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original)); err != nil { @@ -312,12 +303,16 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl ephemeralRunnerMetadataModified := !cmp.Equal(ephemeralRunnerSet.Spec.EphemeralRunnerMetadata, desired.Spec.EphemeralRunnerMetadata) ephemeralRunnerLabelsModified := !maps.Equal(ephemeralRunnerSet.Labels, desired.Labels) ephemeralRunnerAnnotationsModified := !maps.Equal(ephemeralRunnerSet.Annotations, desired.Annotations) + ephemeralRunnerReplicasModified := ephemeralRunnerSet.Spec.Replicas != desired.Spec.Replicas + ephemeralRunnerPatchIDModified := ephemeralRunnerSet.Spec.PatchID != desired.Spec.PatchID - if ephemeralRunnerLabelsModified || ephemeralRunnerAnnotationsModified || ephemeralRunnerMetadataModified { + if ephemeralRunnerLabelsModified || ephemeralRunnerAnnotationsModified || ephemeralRunnerMetadataModified || ephemeralRunnerReplicasModified || ephemeralRunnerPatchIDModified { original := ephemeralRunnerSet.DeepCopy() ephemeralRunnerSet.Labels = r.filterAndMergeLabels(ephemeralRunnerSet.Labels, desired.Labels) - ephemeralRunnerSet.Annotations = r.mergeAnnotations(ephemeralRunnerSet.Annotations, desired.Annotations) + ephemeralRunnerSet.Annotations = desired.Annotations ephemeralRunnerSet.Spec.EphemeralRunnerMetadata = desired.Spec.EphemeralRunnerMetadata + ephemeralRunnerSet.Spec.Replicas = desired.Spec.Replicas + ephemeralRunnerSet.Spec.PatchID = desired.Spec.PatchID log.Info("Updating ephemeral runner set metadata to match desired labels and annotations") if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original)); err != nil { log.Error(err, "Failed to patch ephemeral runner set metadata to match desired labels and annotations") @@ -376,6 +371,7 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl ctx, &autoscalingRunnerSet, v1alpha1.AutoscalingRunnerSetPhaseRunning, + autoscalingRunnerSet.Generation, log, ); err != nil { log.Error(err, "Failed to update autoscaling runner set status to running") @@ -420,14 +416,22 @@ func (r *AutoscalingRunnerSetReconciler) cleanUpResources(ctx context.Context, a } // Update the status of autoscaling runner set if necessary -func (r *AutoscalingRunnerSetReconciler) updateStatus(ctx context.Context, autoscalingRunnerSet *v1alpha1.AutoscalingRunnerSet, phase v1alpha1.AutoscalingRunnerSetPhase, log logr.Logger) error { +func (r *AutoscalingRunnerSetReconciler) updateStatus( + ctx context.Context, + autoscalingRunnerSet *v1alpha1.AutoscalingRunnerSet, + phase v1alpha1.AutoscalingRunnerSetPhase, + observedGeneration int64, + log logr.Logger, +) error { phaseDiff := phase != autoscalingRunnerSet.Status.Phase - if !phaseDiff { + observedGenerationDiff := observedGeneration != autoscalingRunnerSet.Status.ObservedGeneration + if !phaseDiff && !observedGenerationDiff { return nil } original := autoscalingRunnerSet.DeepCopy() autoscalingRunnerSet.Status.Phase = phase + autoscalingRunnerSet.Status.ObservedGeneration = observedGeneration if err := r.Status().Patch(ctx, autoscalingRunnerSet, client.MergeFrom(original)); err != nil { log.Error(err, "Failed to patch autoscaling runner set status") @@ -755,6 +759,7 @@ func (r *AutoscalingRunnerSetReconciler) createEphemeralRunnerSet(ctx context.Co log.Error(err, "Could not create EphemeralRunnerSet") return ctrl.Result{}, err } + desiredRunnerSet.Spec.ActionableRevision = 1 log.Info("Creating a new EphemeralRunnerSet resource") if err := r.Create(ctx, desiredRunnerSet); err != nil { diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go index 3cbe719fc8..a05fb292d3 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller_test.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller_test.go @@ -511,7 +511,7 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { autoscalingRunnerSetTestInterval, ).Should(Succeed(), "EphemeralRunnerSet should be created") originalRunnerSetUID := runnerSet.UID - originalRunnerSetHash := runnerSet.Annotations[annotationKeyIntegrityHash] + originalActionableRevision := runnerSet.Spec.ActionableRevision patched := autoscalingRunnerSet.DeepCopy() patched.Spec.Template.Spec.Containers[0].Image = "ghcr.io/actions/runner:updated" @@ -524,8 +524,8 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, current) g.Expect(err).NotTo(HaveOccurred(), "failed to get EphemeralRunnerSet") g.Expect(current.UID).To(Equal(originalRunnerSetUID), "EphemeralRunnerSet should be updated in place") + g.Expect(current.Spec.ActionableRevision).To(BeNumerically(">", originalActionableRevision), "ActionableRevision should increment for actionable spec changes") g.Expect(current.Spec.EphemeralRunnerSpec.PodTemplateSpec.Spec.Containers[0].Image).To(Equal("ghcr.io/actions/runner:updated")) - g.Expect(current.Annotations[annotationKeyIntegrityHash]).NotTo(Equal(originalRunnerSetHash), "EphemeralRunnerSet spec hash should change") }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, @@ -564,7 +564,7 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { autoscalingRunnerSetTestInterval, ).Should(Succeed(), "EphemeralRunnerSet should be created") originalRunnerSetUID := runnerSet.UID - originalRunnerSetHash := runnerSet.Annotations[annotationKeyIntegrityHash] + originalActionableRevision := runnerSet.Spec.ActionableRevision patched := autoscalingRunnerSet.DeepCopy() max := 20 @@ -590,7 +590,7 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingRunnerSet.Name, Namespace: autoscalingRunnerSet.Namespace}, current) g.Expect(err).NotTo(HaveOccurred(), "failed to get EphemeralRunnerSet") g.Expect(current.UID).To(Equal(originalRunnerSetUID), "EphemeralRunnerSet should not be recreated") - g.Expect(current.Annotations[annotationKeyIntegrityHash]).To(Equal(originalRunnerSetHash), "EphemeralRunnerSet spec should not change") + g.Expect(current.Spec.ActionableRevision).To(Equal(originalActionableRevision), "ActionableRevision should not change for non-actionable updates") }, time.Second*5, autoscalingRunnerSetTestInterval, @@ -908,10 +908,6 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { statusUpdate := runnerSet.DeepCopy() statusUpdate.Status.Phase = v1alpha1.EphemeralRunnerSetPhaseRunning - desiredStatus := v1alpha1.AutoscalingRunnerSetStatus{ - Phase: v1alpha1.AutoscalingRunnerSetPhaseRunning, - } - err := k8sClient.Status().Patch(ctx, statusUpdate, client.MergeFrom(&runnerSet)) Expect(err).NotTo(HaveOccurred(), "Failed to patch runner set status") @@ -926,7 +922,10 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() { }, autoscalingRunnerSetTestTimeout, autoscalingRunnerSetTestInterval, - ).Should(BeEquivalentTo(desiredStatus), "AutoScalingRunnerSet status should be updated") + ).Should(SatisfyAll( + WithTransform(func(s v1alpha1.AutoscalingRunnerSetStatus) v1alpha1.AutoscalingRunnerSetPhase { return s.Phase }, Equal(v1alpha1.AutoscalingRunnerSetPhaseRunning)), + WithTransform(func(s v1alpha1.AutoscalingRunnerSetStatus) int64 { return s.ObservedGeneration }, BeNumerically(">=", ars.Generation)), + ), "AutoScalingRunnerSet status should be updated") }) }) diff --git a/controllers/actions.github.com/ephemeralrunner_controller.go b/controllers/actions.github.com/ephemeralrunner_controller.go index 256ed311c4..3371b5854b 100644 --- a/controllers/actions.github.com/ephemeralrunner_controller.go +++ b/controllers/actions.github.com/ephemeralrunner_controller.go @@ -186,8 +186,8 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ log.Info("Successfully added finalizers") } - secret := new(corev1.Secret) - if err := r.Get(ctx, req.NamespacedName, secret); err != nil { + var secret corev1.Secret + if err := r.Get(ctx, req.NamespacedName, &secret); err != nil { if !kerrors.IsNotFound(err) { log.Error(err, "Failed to fetch secret") return ctrl.Result{}, err @@ -203,7 +203,7 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ return ctrl.Result{}, fmt.Errorf("failed to create secret: %w", err) } log.Info("Created new ephemeral runner secret for jitconfig.") - secret = jitSecret + secret = *jitSecret case errors.Is(err, retryableError): log.Info("Encountered retryable error, requeueing", "error", err.Error()) @@ -227,7 +227,7 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ if err != nil { log.Error(err, "Runner config secret is corrupted: missing runnerId") log.Info("Deleting corrupted runner config secret") - if err := r.Delete(ctx, secret); err != nil { + if err := r.Delete(ctx, &secret); err != nil { return ctrl.Result{}, fmt.Errorf("failed to delete the corrupted runner config secret") } log.Info("Corrupted runner config secret has been deleted") @@ -273,15 +273,15 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ }, nil } - pod := new(corev1.Pod) - if err := r.Get(ctx, req.NamespacedName, pod); err != nil { + var pod corev1.Pod + if err := r.Get(ctx, req.NamespacedName, &pod); err != nil { if !kerrors.IsNotFound(err) { log.Error(err, "Failed to fetch the pod") return ctrl.Result{}, err } log.Info("Ephemeral runner pod does not exist. Creating new ephemeral runner") - result, err := r.createPod(ctx, &ephemeralRunner, secret, log) + result, err := r.createPod(ctx, &ephemeralRunner, &secret, log) switch { case err == nil: return result, nil @@ -329,7 +329,7 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ } } - cs := runnerContainerStatus(pod) + cs := runnerContainerStatus(&pod) switch { case pod.Status.Phase == corev1.PodFailed: // All containers are stopped log.Info( @@ -342,7 +342,7 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ // Therefore, we should try to restart it. if cs == nil || cs.State.Terminated == nil { log.Info("Runner container does not have state set, deleting pod as failed so it can be restarted") - return ctrl.Result{}, r.deleteEphemeralRunnerOrPod(ctx, &ephemeralRunner, pod, log) + return ctrl.Result{}, r.deleteEphemeralRunnerOrPod(ctx, &ephemeralRunner, &pod, log) } switch cs.State.Terminated.ExitCode { @@ -352,7 +352,7 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ // If the runner container exits with 0, we assume that the runner has finished successfully. // If side-car container exits with non-zero, it shouldn't affect the runner. Runner exit code // drives the controller's inference of whether the job has succeeded or failed. - if err := r.markAsSucceeded(ctx, &ephemeralRunner, pod, log); err != nil { + if err := r.markAsSucceeded(ctx, &ephemeralRunner, &pod, log); err != nil { log.Error(err, "Failed to set ephemeral runner to phase Succeeded") return ctrl.Result{}, err } @@ -374,14 +374,14 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ "Ephemeral runner container has failed, and runner container termination exit code is non-zero", "containerTerminatedState", cs.State.Terminated, ) - return ctrl.Result{}, r.deleteEphemeralRunnerOrPod(ctx, &ephemeralRunner, pod, log) + return ctrl.Result{}, r.deleteEphemeralRunnerOrPod(ctx, &ephemeralRunner, &pod, log) - case initContainerFailed(pod): + case initContainerFailed(&pod): log.Info( "Pod has a failed init container, deleting pod as failed so it can be restarted", "initContainerStatuses", pod.Status.InitContainerStatuses, ) - return ctrl.Result{}, r.deleteEphemeralRunnerOrPod(ctx, &ephemeralRunner, pod, log) + return ctrl.Result{}, r.deleteEphemeralRunnerOrPod(ctx, &ephemeralRunner, &pod, log) case cs == nil: // starting, no container state yet @@ -390,7 +390,7 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ case cs.State.Terminated == nil: // container is not terminated and pod phase is not failed, so runner is still running log.Info("Runner container is still running; updating ephemeral runner status") - if err := r.updateRunStatusFromPod(ctx, &ephemeralRunner, pod, log); err != nil { + if err := r.updateRunStatusFromPod(ctx, &ephemeralRunner, &pod, log); err != nil { log.Info("Failed to update ephemeral runner status. Requeue to not miss this event") return ctrl.Result{}, err } @@ -405,11 +405,11 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ case cs.State.Terminated.ExitCode != 0: // failed log.Info("Ephemeral runner container failed", "exitCode", cs.State.Terminated.ExitCode) - return ctrl.Result{}, r.deleteEphemeralRunnerOrPod(ctx, &ephemeralRunner, pod, log) + return ctrl.Result{}, r.deleteEphemeralRunnerOrPod(ctx, &ephemeralRunner, &pod, log) default: // succeeded log.Info("Ephemeral runner has finished successfully, deleting ephemeral runner", "exitCode", cs.State.Terminated.ExitCode) - if err := r.markAsSucceeded(ctx, &ephemeralRunner, pod, log); err != nil { + if err := r.markAsSucceeded(ctx, &ephemeralRunner, &pod, log); err != nil { log.Error(err, "Failed to set ephemeral runner to phase Succeeded") return ctrl.Result{}, err } @@ -471,13 +471,13 @@ func (r *EphemeralRunnerReconciler) cleanupRunnerFromService(ctx context.Context func (r *EphemeralRunnerReconciler) cleanupResources(ctx context.Context, ephemeralRunner *v1alpha1.EphemeralRunner, log logr.Logger) error { log.Info("Cleaning up the runner pod") - pod := new(corev1.Pod) - err := r.Get(ctx, types.NamespacedName{Namespace: ephemeralRunner.Namespace, Name: ephemeralRunner.Name}, pod) + var pod corev1.Pod + err := r.Get(ctx, types.NamespacedName{Namespace: ephemeralRunner.Namespace, Name: ephemeralRunner.Name}, &pod) switch { case err == nil: if pod.DeletionTimestamp.IsZero() { log.Info("Deleting the runner pod") - if err := r.Delete(ctx, pod); err != nil && !kerrors.IsNotFound(err) { + if err := r.Delete(ctx, &pod); err != nil && !kerrors.IsNotFound(err) { return fmt.Errorf("failed to delete pod: %w", err) } log.Info("Deleted the runner pod") @@ -491,13 +491,13 @@ func (r *EphemeralRunnerReconciler) cleanupResources(ctx context.Context, epheme } log.Info("Cleaning up the runner jitconfig secret") - secret := new(corev1.Secret) - err = r.Get(ctx, types.NamespacedName{Namespace: ephemeralRunner.Namespace, Name: ephemeralRunner.Name}, secret) + var secret corev1.Secret + err = r.Get(ctx, types.NamespacedName{Namespace: ephemeralRunner.Namespace, Name: ephemeralRunner.Name}, &secret) switch { case err == nil: if secret.DeletionTimestamp.IsZero() { log.Info("Deleting the jitconfig secret") - if err := r.Delete(ctx, secret); err != nil && !kerrors.IsNotFound(err) { + if err := r.Delete(ctx, &secret); err != nil && !kerrors.IsNotFound(err) { return fmt.Errorf("failed to delete secret: %w", err) } log.Info("Deleted jitconfig secret") diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller.go b/controllers/actions.github.com/ephemeralrunnerset_controller.go index c4f6d8c025..964c727196 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller.go @@ -35,6 +35,7 @@ import ( kerrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -133,12 +134,13 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R return ctrl.Result{}, nil } - // If hash spec has changed, delete idle ephemeral runners - // in order to apply the change to the runners that did not yet receive a job. - ephemeralRunnerIntegrityHash := ephemeralRunnerSetIntegrityHash(&ephemeralRunnerSet) - if ephemeralRunnerSet.Annotations[annotationKeyIntegrityHash] != ephemeralRunnerIntegrityHash { - log.Info("EphemeralRunnerSpec has changed, deleting idle ephemeral runners to apply the new spec") - if _, err := r.cleanUpEphemeralRunners(ctx, &ephemeralRunnerSet, log); err != nil { + if ephemeralRunnerSet.Spec.ActionableRevision > ephemeralRunnerSet.Status.AppliedActionableRevision { + log.Info( + "EphemeralRunnerSpec revision has changed, deleting idle or pending ephemeral runners to apply the new spec", + "specActionableRevision", ephemeralRunnerSet.Spec.ActionableRevision, + "statusAppliedActionableRevision", ephemeralRunnerSet.Status.AppliedActionableRevision, + ) + if err := r.cleanUpIdleAndPendingEphemeralRunners(ctx, &ephemeralRunnerSet, log); err != nil { log.Error(err, "Failed to clean up EphemeralRunners") return ctrl.Result{}, err } @@ -148,18 +150,12 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R return ctrl.Result{}, err } - log.Info("Updating EphemeralRunnerSet with new spec hash") - original := ephemeralRunnerSet.DeepCopy() - if ephemeralRunnerSet.Annotations == nil { - ephemeralRunnerSet.Annotations = make(map[string]string) - } - ephemeralRunnerSet.Annotations[annotationKeyIntegrityHash] = ephemeralRunnerIntegrityHash - if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original)); err != nil { - log.Error(err, "Failed to update ephemeral runner set with new spec hash") + if err := r.patchAppliedActionableRevisionStatus(ctx, req.NamespacedName, ephemeralRunnerSet.Spec.ActionableRevision); err != nil { + log.Error(err, "Failed to update EphemeralRunnerSet applied actionable revision status") return ctrl.Result{}, err } - log.Info("Updated ephemeral runner set with new spec hash") + log.Info("Updated EphemeralRunnerSet applied actionable revision status", "appliedActionableRevision", ephemeralRunnerSet.Spec.ActionableRevision) return ctrl.Result{}, nil } @@ -245,6 +241,33 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R return ctrl.Result{}, r.updateStatus(ctx, &ephemeralRunnerSet, ephemeralRunnersByState, log) } +func (r *EphemeralRunnerSetReconciler) patchAppliedActionableRevisionStatus(ctx context.Context, key types.NamespacedName, targetAppliedRevision int64) error { + return retry.RetryOnConflict(retry.DefaultBackoff, func() error { + var latest v1alpha1.EphemeralRunnerSet + if err := r.Get(ctx, key, &latest); err != nil { + return err + } + + original := latest.DeepCopy() + latest.Status.AppliedActionableRevision = targetAppliedRevision + + ephemeralRunnerList := new(v1alpha1.EphemeralRunnerList) + if err := r.List(ctx, ephemeralRunnerList, client.InNamespace(latest.Namespace), client.MatchingFields{resourceOwnerKey: latest.Name}); err != nil { + return fmt.Errorf("failed to list child ephemeral runners: %w", err) + } + + if len(newEphemeralRunnersByStates(ephemeralRunnerList).outdated) == 0 { + latest.Status.Phase = v1alpha1.EphemeralRunnerSetPhaseRunning + } + + if original.Status == latest.Status { + return nil + } + + return r.Status().Patch(ctx, &latest, client.MergeFrom(original)) + }) +} + func (r *EphemeralRunnerSetReconciler) updateStatus(ctx context.Context, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, state *ephemeralRunnersByState, log logr.Logger) error { original := ephemeralRunnerSet.DeepCopy() var phase v1alpha1.EphemeralRunnerSetPhase @@ -257,7 +280,8 @@ func (r *EphemeralRunnerSetReconciler) updateStatus(ctx context.Context, ephemer phase = ephemeralRunnerSet.Status.Phase } desiredStatus := v1alpha1.EphemeralRunnerSetStatus{ - Phase: phase, + Phase: phase, + AppliedActionableRevision: ephemeralRunnerSet.Status.AppliedActionableRevision, } // Update the status if needed. @@ -398,6 +422,60 @@ func (r *EphemeralRunnerSetReconciler) cleanUpEphemeralRunners(ctx context.Conte return false, nil } +func (r *EphemeralRunnerSetReconciler) cleanUpIdleAndPendingEphemeralRunners(ctx context.Context, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, log logr.Logger) error { + ephemeralRunnerList := new(v1alpha1.EphemeralRunnerList) + err := r.List(ctx, ephemeralRunnerList, client.InNamespace(ephemeralRunnerSet.Namespace), client.MatchingFields{resourceOwnerKey: ephemeralRunnerSet.Name}) + if err != nil { + return fmt.Errorf("failed to list child ephemeral runners: %w", err) + } + + ephemeralRunnerState := newEphemeralRunnersByStates(ephemeralRunnerList) + if len(ephemeralRunnerState.running) == 0 && len(ephemeralRunnerState.pending) == 0 { + return nil + } + + actionsClient, err := r.GetActionsService(ctx, ephemeralRunnerSet) + if err != nil { + return err + } + + log.Info("Cleanup pending or idle ephemeral runners", "pending", len(ephemeralRunnerState.pending), "running", len(ephemeralRunnerState.running)) + var errs []error + for _, ephemeralRunner := range ephemeralRunnerState.pending { + log.Info("Removing the pending ephemeral runner from the service", "name", ephemeralRunner.Name) + _, err := r.deleteEphemeralRunnerWithActionsClient(ctx, ephemeralRunner, actionsClient, log) + if err != nil { + errs = append(errs, err) + } + } + + for _, ephemeralRunner := range ephemeralRunnerState.running { + if ephemeralRunner.HasJob() { + log.Info( + "Skipping ephemeral runner since it is running a job", + "name", ephemeralRunner.Name, + "workflowRunId", ephemeralRunner.Status.WorkflowRunID, + "jobId", ephemeralRunner.Status.JobID, + ) + continue + } + + log.Info("Removing the idle ephemeral runner from the service", "name", ephemeralRunner.Name) + _, err := r.deleteEphemeralRunnerWithActionsClient(ctx, ephemeralRunner, actionsClient, log) + if err != nil { + errs = append(errs, err) + } + } + + if len(errs) > 0 { + mergedErrs := multierr.Combine(errs...) + log.Error(mergedErrs, "Failed to remove idle or pending ephemeral runners from the service") + return mergedErrs + } + + return nil +} + func (r *EphemeralRunnerSetReconciler) cleanUpEphemeralRunnerSetProxySecret(ctx context.Context, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, log logr.Logger) (done bool, err error) { if ephemeralRunnerSet.Spec.EphemeralRunnerSpec.Proxy == nil { return true, nil diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go index 331e8e596c..1d25203e07 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go @@ -1117,6 +1117,7 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { updated = ers.DeepCopy() updated.Spec.EphemeralRunnerSpec.PodTemplateSpec.Spec.Containers[0].Image = "ghcr.io/actions/runner:new" + updated.Spec.ActionableRevision = ers.Spec.ActionableRevision + 1 err = k8sClient.Patch(ctx, updated, client.MergeFrom(ers)) Expect(err).NotTo(HaveOccurred(), "failed to patch EphemeralRunnerSet with new spec") @@ -1491,6 +1492,383 @@ var _ = Describe("EphemeralRunner phase metrics", func() { }) }) +var _ = Describe("Test EphemeralRunnerSet actionable revision cleanup", func() { + var ctx context.Context + var mgr ctrl.Manager + var autoscalingNS *corev1.Namespace + var configSecret *corev1.Secret + + newRunner := func(name string, ers *v1alpha1.EphemeralRunnerSet) *v1alpha1.EphemeralRunner { + controllerRef := true + return &v1alpha1.EphemeralRunner{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: ers.Namespace, + OwnerReferences: []metav1.OwnerReference{{ + APIVersion: v1alpha1.GroupVersion.String(), + Kind: "EphemeralRunnerSet", + Name: ers.Name, + UID: ers.UID, + Controller: &controllerRef, + }}, + }, + Spec: ers.Spec.EphemeralRunnerSpec, + } + } + + BeforeEach(func() { + ctx = context.Background() + autoscalingNS, mgr = createNamespace(GinkgoT(), k8sClient) + configSecret = createDefaultSecret(GinkgoT(), k8sClient, autoscalingNS.Name) + startManagers(GinkgoT(), mgr) + }) + + It("deletes runner-a-idle, keeps runner-b-busy, and advances applied actionable revision 3 to 4", func() { + controller := &EphemeralRunnerSetReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Log: logf.Log, + ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), + SecretResolver: secretresolver.New(mgr.GetClient(), fake.NewMultiClient( + fake.WithClient(fake.NewClient(fake.WithRemoveRunner(nil))), + )), + }, + } + + ephemeralRunnerSet := &v1alpha1.EphemeralRunnerSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test-actionable-revision-success", Namespace: autoscalingNS.Name}, + Spec: v1alpha1.EphemeralRunnerSetSpec{ + ActionableRevision: 3, + EphemeralRunnerSpec: v1alpha1.EphemeralRunnerSpec{ + GitHubConfigURL: "https://github.com/owner/repo", + GitHubConfigSecret: configSecret.Name, + RunnerScaleSetID: 100, + PodTemplateSpec: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "runner", Image: "ghcr.io/actions/runner"}}}}, + }, + }, + } + + err := k8sClient.Create(ctx, ephemeralRunnerSet) + Expect(err).NotTo(HaveOccurred()) + + request := ctrl.Request{NamespacedName: types.NamespacedName{Name: ephemeralRunnerSet.Name, Namespace: ephemeralRunnerSet.Namespace}} + _, err = controller.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + + current := new(v1alpha1.EphemeralRunnerSet) + err = k8sClient.Get(ctx, request.NamespacedName, current) + Expect(err).NotTo(HaveOccurred()) + + statusUpdated := current.DeepCopy() + statusUpdated.Status.AppliedActionableRevision = 3 + statusUpdated.Status.Phase = v1alpha1.EphemeralRunnerSetPhaseRunning + err = k8sClient.Status().Patch(ctx, statusUpdated, client.MergeFrom(current)) + Expect(err).NotTo(HaveOccurred()) + + idleRunner := newRunner("runner-a-idle", statusUpdated) + err = k8sClient.Create(ctx, idleRunner) + Expect(err).NotTo(HaveOccurred()) + + idleCurrent := new(v1alpha1.EphemeralRunner) + err = k8sClient.Get(ctx, client.ObjectKeyFromObject(idleRunner), idleCurrent) + Expect(err).NotTo(HaveOccurred()) + idleUpdated := idleCurrent.DeepCopy() + idleUpdated.Status.Phase = v1alpha1.EphemeralRunnerPhaseRunning + idleUpdated.Status.RunnerID = 101 + err = k8sClient.Status().Patch(ctx, idleUpdated, client.MergeFrom(idleCurrent)) + Expect(err).NotTo(HaveOccurred()) + + busyRunner := newRunner("runner-b-busy", statusUpdated) + err = k8sClient.Create(ctx, busyRunner) + Expect(err).NotTo(HaveOccurred()) + + busyCurrent := new(v1alpha1.EphemeralRunner) + err = k8sClient.Get(ctx, client.ObjectKeyFromObject(busyRunner), busyCurrent) + Expect(err).NotTo(HaveOccurred()) + busyUpdated := busyCurrent.DeepCopy() + busyUpdated.Status.Phase = v1alpha1.EphemeralRunnerPhaseRunning + busyUpdated.Status.RunnerID = 102 + busyUpdated.Status.JobID = "job-1" + busyUpdated.Status.WorkflowRunID = 9001 + err = k8sClient.Status().Patch(ctx, busyUpdated, client.MergeFrom(busyCurrent)) + Expect(err).NotTo(HaveOccurred()) + + err = k8sClient.Get(ctx, request.NamespacedName, current) + Expect(err).NotTo(HaveOccurred()) + specUpdated := current.DeepCopy() + specUpdated.Spec.ActionableRevision = 4 + err = k8sClient.Patch(ctx, specUpdated, client.MergeFrom(current)) + Expect(err).NotTo(HaveOccurred()) + + _, err = controller.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + + Eventually(func() bool { + runner := new(v1alpha1.EphemeralRunner) + return kerrors.IsNotFound(k8sClient.Get(ctx, types.NamespacedName{Namespace: autoscalingNS.Name, Name: "runner-a-idle"}, runner)) + }, ephemeralRunnerSetTestTimeout, ephemeralRunnerSetTestInterval).Should(BeTrue()) + + Consistently(func() error { + runner := new(v1alpha1.EphemeralRunner) + return k8sClient.Get(ctx, types.NamespacedName{Namespace: autoscalingNS.Name, Name: "runner-b-busy"}, runner) + }, time.Second, ephemeralRunnerSetTestInterval).Should(Succeed()) + + Eventually(func() int64 { + updatedSet := new(v1alpha1.EphemeralRunnerSet) + if err := k8sClient.Get(ctx, request.NamespacedName, updatedSet); err != nil { + return 0 + } + return updatedSet.Status.AppliedActionableRevision + }, ephemeralRunnerSetTestTimeout, ephemeralRunnerSetTestInterval).Should(Equal(int64(4))) + }) + + It("keeps applied actionable revision at 3 when cleanup fails", func() { + controller := &EphemeralRunnerSetReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Log: logf.Log, + ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), + SecretResolver: secretresolver.New(mgr.GetClient(), fake.NewMultiClient( + fake.WithClient(fake.NewClient(fake.WithRemoveRunner(fmt.Errorf("remove failed")))), + )), + }, + } + + ephemeralRunnerSet := &v1alpha1.EphemeralRunnerSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test-actionable-revision-error", Namespace: autoscalingNS.Name}, + Spec: v1alpha1.EphemeralRunnerSetSpec{ + ActionableRevision: 3, + EphemeralRunnerSpec: v1alpha1.EphemeralRunnerSpec{ + GitHubConfigURL: "https://github.com/owner/repo", + GitHubConfigSecret: configSecret.Name, + RunnerScaleSetID: 100, + PodTemplateSpec: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "runner", Image: "ghcr.io/actions/runner"}}}}, + }, + }, + } + + err := k8sClient.Create(ctx, ephemeralRunnerSet) + Expect(err).NotTo(HaveOccurred()) + + request := ctrl.Request{NamespacedName: types.NamespacedName{Name: ephemeralRunnerSet.Name, Namespace: ephemeralRunnerSet.Namespace}} + _, err = controller.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + + current := new(v1alpha1.EphemeralRunnerSet) + err = k8sClient.Get(ctx, request.NamespacedName, current) + Expect(err).NotTo(HaveOccurred()) + + statusUpdated := current.DeepCopy() + statusUpdated.Status.AppliedActionableRevision = 3 + err = k8sClient.Status().Patch(ctx, statusUpdated, client.MergeFrom(current)) + Expect(err).NotTo(HaveOccurred()) + + idleRunner := newRunner("runner-a-idle", statusUpdated) + err = k8sClient.Create(ctx, idleRunner) + Expect(err).NotTo(HaveOccurred()) + + idleCurrent := new(v1alpha1.EphemeralRunner) + err = k8sClient.Get(ctx, client.ObjectKeyFromObject(idleRunner), idleCurrent) + Expect(err).NotTo(HaveOccurred()) + idleUpdated := idleCurrent.DeepCopy() + idleUpdated.Status.Phase = v1alpha1.EphemeralRunnerPhaseRunning + idleUpdated.Status.RunnerID = 101 + err = k8sClient.Status().Patch(ctx, idleUpdated, client.MergeFrom(idleCurrent)) + Expect(err).NotTo(HaveOccurred()) + + err = k8sClient.Get(ctx, request.NamespacedName, current) + Expect(err).NotTo(HaveOccurred()) + specUpdated := current.DeepCopy() + specUpdated.Spec.ActionableRevision = 4 + err = k8sClient.Patch(ctx, specUpdated, client.MergeFrom(current)) + Expect(err).NotTo(HaveOccurred()) + + _, err = controller.Reconcile(ctx, request) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("remove failed")) + + Consistently(func() int64 { + updatedSet := new(v1alpha1.EphemeralRunnerSet) + if err := k8sClient.Get(ctx, request.NamespacedName, updatedSet); err != nil { + return 0 + } + return updatedSet.Status.AppliedActionableRevision + }, time.Second, ephemeralRunnerSetTestInterval).Should(Equal(int64(3))) + }) + + It("deletes idle runner and advances revision after restart with no cache", func() { + controller := &EphemeralRunnerSetReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Log: logf.Log, + ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), // fresh empty cache simulating restart + SecretResolver: secretresolver.New(mgr.GetClient(), fake.NewMultiClient()), + }, + } + + ephemeralRunnerSet := &v1alpha1.EphemeralRunnerSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test-restart-no-cache", Namespace: autoscalingNS.Name}, + Spec: v1alpha1.EphemeralRunnerSetSpec{ + ActionableRevision: 4, // spec has been bumped + EphemeralRunnerSpec: v1alpha1.EphemeralRunnerSpec{ + GitHubConfigURL: "https://github.com/owner/repo", + GitHubConfigSecret: configSecret.Name, + RunnerScaleSetID: 100, + PodTemplateSpec: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "runner", Image: "ghcr.io/actions/runner:updated"}}}}, + }, + }, + } + + err := k8sClient.Create(ctx, ephemeralRunnerSet) + Expect(err).NotTo(HaveOccurred()) + + request := ctrl.Request{NamespacedName: types.NamespacedName{Name: ephemeralRunnerSet.Name, Namespace: ephemeralRunnerSet.Namespace}} + _, err = controller.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + + current := new(v1alpha1.EphemeralRunnerSet) + err = k8sClient.Get(ctx, request.NamespacedName, current) + Expect(err).NotTo(HaveOccurred()) + + statusUpdated := current.DeepCopy() + statusUpdated.Status.AppliedActionableRevision = 3 // status is behind + err = k8sClient.Status().Patch(ctx, statusUpdated, client.MergeFrom(current)) + Expect(err).NotTo(HaveOccurred()) + + // Create idle runner (running but no job) + idleRunner := newRunner("runner-restart-idle", statusUpdated) + err = k8sClient.Create(ctx, idleRunner) + Expect(err).NotTo(HaveOccurred()) + + idleCurrent := new(v1alpha1.EphemeralRunner) + err = k8sClient.Get(ctx, client.ObjectKeyFromObject(idleRunner), idleCurrent) + Expect(err).NotTo(HaveOccurred()) + idleUpdated := idleCurrent.DeepCopy() + idleUpdated.Status.Phase = v1alpha1.EphemeralRunnerPhaseRunning + idleUpdated.Status.RunnerID = 201 + err = k8sClient.Status().Patch(ctx, idleUpdated, client.MergeFrom(idleCurrent)) + Expect(err).NotTo(HaveOccurred()) + + // Reconcile with fresh cache (simulating restart) + _, err = controller.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + + // Idle runner should be deleted + Eventually(func() bool { + runner := new(v1alpha1.EphemeralRunner) + return kerrors.IsNotFound(k8sClient.Get(ctx, types.NamespacedName{Namespace: autoscalingNS.Name, Name: "runner-restart-idle"}, runner)) + }, ephemeralRunnerSetTestTimeout, ephemeralRunnerSetTestInterval).Should(BeTrue()) + + // AppliedActionableRevision should advance to 4 + Eventually(func() int64 { + updatedSet := new(v1alpha1.EphemeralRunnerSet) + if err := k8sClient.Get(ctx, request.NamespacedName, updatedSet); err != nil { + return 0 + } + return updatedSet.Status.AppliedActionableRevision + }, ephemeralRunnerSetTestTimeout, ephemeralRunnerSetTestInterval).Should(Equal(int64(4))) + }) + + It("preserves AppliedActionableRevision during status-only phase updates", func() { + controller := &EphemeralRunnerSetReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Log: logf.Log, + ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), + SecretResolver: secretresolver.New(mgr.GetClient(), fake.NewMultiClient( + fake.WithClient(fake.NewClient()), + )), + }, + } + + // Setup: Create ERS with an actionable revision + ephemeralRunnerSet := &v1alpha1.EphemeralRunnerSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test-preserve-applied-revision", Namespace: autoscalingNS.Name}, + Spec: v1alpha1.EphemeralRunnerSetSpec{ + ActionableRevision: 5, + EphemeralRunnerSpec: v1alpha1.EphemeralRunnerSpec{ + GitHubConfigURL: "https://github.com/owner/repo", + GitHubConfigSecret: configSecret.Name, + RunnerScaleSetID: 100, + PodTemplateSpec: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "runner", Image: "ghcr.io/actions/runner"}}}}, + }, + }, + } + + err := k8sClient.Create(ctx, ephemeralRunnerSet) + Expect(err).NotTo(HaveOccurred()) + + request := ctrl.Request{NamespacedName: types.NamespacedName{Name: ephemeralRunnerSet.Name, Namespace: ephemeralRunnerSet.Namespace}} + _, err = controller.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + + // Set AppliedActionableRevision to 5 + current := new(v1alpha1.EphemeralRunnerSet) + err = k8sClient.Get(ctx, request.NamespacedName, current) + Expect(err).NotTo(HaveOccurred()) + + statusUpdated := current.DeepCopy() + statusUpdated.Status.AppliedActionableRevision = 5 + statusUpdated.Status.Phase = v1alpha1.EphemeralRunnerSetPhaseRunning + err = k8sClient.Status().Patch(ctx, statusUpdated, client.MergeFrom(current)) + Expect(err).NotTo(HaveOccurred()) + + // Create a runner that will cause phase change (outdated runner) + ephemeralRunner := &v1alpha1.EphemeralRunner{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-runner-outdated", + Namespace: autoscalingNS.Name, + Labels: map[string]string{ + LabelKeyGitHubScaleSetName: ephemeralRunnerSet.Name, + LabelKeyGitHubScaleSetNamespace: ephemeralRunnerSet.Namespace, + }, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: v1alpha1.GroupVersion.String(), + Kind: "EphemeralRunnerSet", + Name: ephemeralRunnerSet.Name, + UID: ephemeralRunnerSet.UID, + Controller: func(b bool) *bool { return &b }(true), + BlockOwnerDeletion: func(b bool) *bool { return &b }(true), + }, + }, + }, + Spec: v1alpha1.EphemeralRunnerSpec{ + GitHubConfigURL: "https://github.com/owner/repo", + GitHubConfigSecret: configSecret.Name, + RunnerScaleSetID: 100, + PodTemplateSpec: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "runner", Image: "ghcr.io/actions/runner:old"}}}}, + }, + } + err = k8sClient.Create(ctx, ephemeralRunner) + Expect(err).NotTo(HaveOccurred()) + + runnerStatusUpdated := ephemeralRunner.DeepCopy() + runnerStatusUpdated.Status.Phase = v1alpha1.EphemeralRunnerPhaseOutdated + runnerStatusUpdated.Status.RunnerID = 123 + runnerStatusUpdated.Status.JobRequestID = 456 + err = k8sClient.Status().Patch(ctx, runnerStatusUpdated, client.MergeFrom(ephemeralRunner)) + Expect(err).NotTo(HaveOccurred()) + + // Reconcile - should detect outdated runner and change phase to Outdated + _, err = controller.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + + // Verify: Phase changed to Outdated, but AppliedActionableRevision preserved + Eventually(func(g Gomega) { + updatedSet := new(v1alpha1.EphemeralRunnerSet) + err := k8sClient.Get(ctx, request.NamespacedName, updatedSet) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(updatedSet.Status.Phase).To(Equal(v1alpha1.EphemeralRunnerSetPhaseOutdated), "phase should change to Outdated") + g.Expect(updatedSet.Status.AppliedActionableRevision).To(Equal(int64(5)), "AppliedActionableRevision should be preserved") + }, ephemeralRunnerSetTestTimeout, ephemeralRunnerSetTestInterval).Should(Succeed()) + }) +}) + var _ = Describe("Test EphemeralRunnerSet controller with proxy settings", func() { var ctx context.Context var mgr ctrl.Manager diff --git a/controllers/actions.github.com/helpers.go b/controllers/actions.github.com/helpers.go new file mode 100644 index 0000000000..8d90f5ae65 --- /dev/null +++ b/controllers/actions.github.com/helpers.go @@ -0,0 +1,65 @@ +package actionsgithubcom + +import ( + "github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1" + "github.com/google/go-cmp/cmp" + corev1 "k8s.io/api/core/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var ( + _ = ephemeralRunnerSetActionableSpecChanged + _ = nextActionableRevision + _ = listenerPodCanonicalEqual +) + +func ephemeralRunnerSetActionableSpecChanged(current, desired *v1alpha1.EphemeralRunnerSet) bool { + if current == nil || desired == nil { + return current != desired + } + + return !cmp.Equal(current.Spec.EphemeralRunnerSpec, desired.Spec.EphemeralRunnerSpec) +} + +func nextActionableRevision(current *v1alpha1.EphemeralRunnerSet) int64 { + if current == nil { + return 1 + } + + if current.Spec.ActionableRevision > current.Status.AppliedActionableRevision { + return current.Spec.ActionableRevision + 1 + } + + return current.Status.AppliedActionableRevision + 1 +} + +func listenerPodCanonicalForComparison(pod *corev1.Pod) *corev1.Pod { + if pod == nil { + return nil + } + + canonical := pod.DeepCopy() + canonical.UID = "" + canonical.ResourceVersion = "" + canonical.ManagedFields = nil + canonical.CreationTimestamp = metav1.Time{} + canonical.DeletionTimestamp = nil + canonical.Finalizers = nil + canonical.Generation = 0 + canonical.Status = corev1.PodStatus{} + + return canonical +} + +func listenerPodCanonicalEqual(current, desired *corev1.Pod) bool { + return cmp.Equal(listenerPodCanonicalForComparison(current), listenerPodCanonicalForComparison(desired)) +} + +func listenerPodSpecRequiresRecreation(current, desired *corev1.Pod) bool { + if current == nil || desired == nil { + return current != desired + } + + return !apiequality.Semantic.DeepDerivative(desired.Spec, current.Spec) +} diff --git a/controllers/actions.github.com/helpers_test.go b/controllers/actions.github.com/helpers_test.go index b798958623..f426db37f5 100644 --- a/controllers/actions.github.com/helpers_test.go +++ b/controllers/actions.github.com/helpers_test.go @@ -2,8 +2,11 @@ package actionsgithubcom import ( "context" + "testing" + "github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1" "github.com/onsi/ginkgo/v2" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" corev1 "k8s.io/api/core/v1" @@ -85,3 +88,197 @@ func createDefaultSecret(t ginkgo.GinkgoTInterface, client client.Client, namesp return secret } + +func TestEphemeralRunnerSetActionableSpecChanged(t *testing.T) { + base := func() *v1alpha1.EphemeralRunnerSet { + return &v1alpha1.EphemeralRunnerSet{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{"app": "arc"}, + Annotations: map[string]string{"note": "keep"}, + }, + Spec: v1alpha1.EphemeralRunnerSetSpec{ + Replicas: 1, + PatchID: 10, + EphemeralRunnerSpec: v1alpha1.EphemeralRunnerSpec{ + PodTemplateSpec: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "runner", Image: "ghcr.io/actions/runner:old"}}, + }, + }, + }, + EphemeralRunnerMetadata: &v1alpha1.ResourceMeta{ + Labels: map[string]string{"meta-label": "v1"}, + Annotations: map[string]string{"meta-annotation": "v1"}, + }, + }, + } + } + + tests := []struct { + name string + mutate func(current, desired *v1alpha1.EphemeralRunnerSet) + want bool + }{ + { + name: "ephemeral runner image change is actionable", + mutate: func(_ *v1alpha1.EphemeralRunnerSet, desired *v1alpha1.EphemeralRunnerSet) { + desired.Spec.EphemeralRunnerSpec.PodTemplateSpec.Spec.Containers[0].Image = "ghcr.io/actions/runner:new" + }, + want: true, + }, + { + name: "ephemeral runner template change is actionable", + mutate: func(_ *v1alpha1.EphemeralRunnerSet, desired *v1alpha1.EphemeralRunnerSet) { + desired.Spec.EphemeralRunnerSpec.PodTemplateSpec.Spec.NodeSelector = map[string]string{"kubernetes.io/os": "linux"} + }, + want: true, + }, + { + name: "replicas change is non-actionable", + mutate: func(_ *v1alpha1.EphemeralRunnerSet, desired *v1alpha1.EphemeralRunnerSet) { + desired.Spec.Replicas = 3 + }, + want: false, + }, + { + name: "patch id change is non-actionable", + mutate: func(_ *v1alpha1.EphemeralRunnerSet, desired *v1alpha1.EphemeralRunnerSet) { + desired.Spec.PatchID = 11 + }, + want: false, + }, + { + name: "set labels change is non-actionable", + mutate: func(_ *v1alpha1.EphemeralRunnerSet, desired *v1alpha1.EphemeralRunnerSet) { + desired.Labels["app"] = "changed" + }, + want: false, + }, + { + name: "set annotations change is non-actionable", + mutate: func(_ *v1alpha1.EphemeralRunnerSet, desired *v1alpha1.EphemeralRunnerSet) { + desired.Annotations["note"] = "changed" + }, + want: false, + }, + { + name: "ephemeral runner metadata change is non-actionable", + mutate: func(_ *v1alpha1.EphemeralRunnerSet, desired *v1alpha1.EphemeralRunnerSet) { + desired.Spec.EphemeralRunnerMetadata.Annotations["meta-annotation"] = "v2" + }, + want: false, + }, + { + name: "nil metadata transition is non-actionable", + mutate: func(current, desired *v1alpha1.EphemeralRunnerSet) { + current.Spec.EphemeralRunnerMetadata = nil + desired.Spec.EphemeralRunnerMetadata = &v1alpha1.ResourceMeta{Labels: map[string]string{"meta-label": "new"}} + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + current := base() + desired := current.DeepCopy() + tt.mutate(current, desired) + + assert.Equal(t, tt.want, ephemeralRunnerSetActionableSpecChanged(current, desired)) + }) + } +} + +func TestNextActionableRevision(t *testing.T) { + tests := []struct { + name string + current *v1alpha1.EphemeralRunnerSet + want int64 + }{ + {name: "nil current starts at one", current: nil, want: 1}, + { + name: "spec revision ahead", + current: &v1alpha1.EphemeralRunnerSet{Spec: v1alpha1.EphemeralRunnerSetSpec{ActionableRevision: 3}, Status: v1alpha1.EphemeralRunnerSetStatus{AppliedActionableRevision: 2}}, + want: 4, + }, + { + name: "applied revision ahead", + current: &v1alpha1.EphemeralRunnerSet{Spec: v1alpha1.EphemeralRunnerSetSpec{ActionableRevision: 2}, Status: v1alpha1.EphemeralRunnerSetStatus{AppliedActionableRevision: 7}}, + want: 8, + }, + { + name: "equal revisions", + current: &v1alpha1.EphemeralRunnerSet{Spec: v1alpha1.EphemeralRunnerSetSpec{ActionableRevision: 5}, Status: v1alpha1.EphemeralRunnerSetStatus{AppliedActionableRevision: 5}}, + want: 6, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, nextActionableRevision(tt.current)) + }) + } +} + +func TestListenerPodCanonicalEqual(t *testing.T) { + base := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "controller-ns", + UID: "uid-1", + ResourceVersion: "100", + Annotations: map[string]string{"keep": "v"}, + Labels: map[string]string{"app": "listener"}, + ManagedFields: []metav1.ManagedFieldsEntry{{Manager: "kube-controller-manager"}}, + }, + Spec: corev1.PodSpec{ + ServiceAccountName: "listener-sa", + Containers: []corev1.Container{{Name: "listener", Image: "ghcr.io/actions/listener:v1"}}, + }, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } + + tests := []struct { + name string + mutate func(current, desired *corev1.Pod) + want bool + }{ + { + name: "ignores runtime fields", + mutate: func(current, desired *corev1.Pod) { + current.UID = "uid-current" + desired.UID = "uid-desired" + current.ResourceVersion = "101" + desired.ResourceVersion = "202" + current.ManagedFields = []metav1.ManagedFieldsEntry{{Manager: "a"}} + desired.ManagedFields = []metav1.ManagedFieldsEntry{{Manager: "b"}} + current.Status.Phase = corev1.PodPending + desired.Status.Phase = corev1.PodFailed + }, + want: true, + }, + { + name: "spec change is not equal", + mutate: func(_ *corev1.Pod, desired *corev1.Pod) { + desired.Spec.Containers[0].Image = "ghcr.io/actions/listener:v2" + }, + want: false, + }, + { + name: "non-legacy annotation change is not equal", + mutate: func(_ *corev1.Pod, desired *corev1.Pod) { + desired.Annotations["keep"] = "different" + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + current := base.DeepCopy() + desired := base.DeepCopy() + tt.mutate(current, desired) + assert.Equal(t, tt.want, listenerPodCanonicalEqual(current, desired)) + }) + } +} diff --git a/controllers/actions.github.com/resourcebuilder.go b/controllers/actions.github.com/resourcebuilder.go index a75d414c57..cda96f18f1 100644 --- a/controllers/actions.github.com/resourcebuilder.go +++ b/controllers/actions.github.com/resourcebuilder.go @@ -46,15 +46,6 @@ var commonLabelKeys = [...]string{ LabelKeyGitHubRepository, } -// annotationKeyIntegrityHash is used as a hash of the important fields -// of each resource to determine if more drastic action should be taken. -// -// For example, annotations/labels are not something that should modify -// the behavior of a resource, while the change in spec is. Therefore, -// the spec hash should contain the spec fields in order to determine -// modifications. -const annotationKeyIntegrityHash = "actions.github.com/integrity-hash" - const labelValueKubernetesPartOf = "gha-runner-scale-set" var ( @@ -137,7 +128,8 @@ func (b *ResourceBuilder) newAutoscalingListener(autoscalingRunnerSet *v1alpha1. Image: image, ImagePullSecrets: imagePullSecrets, }) - if cached, ok := b.ResourceCache.autoscalingListener.Get(autoscalingRunnerSet, cacheKeyObject, ephemeralRunnerSet, inputDependency); ok { + metadataDependency := resourceCacheObjectMetadataInputObject(autoscalingRunnerSet) + if cached, ok := b.ResourceCache.autoscalingListener.Get(autoscalingRunnerSet, cacheKeyObject, ephemeralRunnerSet, inputDependency, metadataDependency); ok { return cached, nil } @@ -184,15 +176,12 @@ func (b *ResourceBuilder) newAutoscalingListener(autoscalingRunnerSet *v1alpha1. return nil, fmt.Errorf("failed to apply GitHub URL labels: %v", err) } - annotations := map[string]string{ - annotationKeyIntegrityHash: spec.Hash(), - } + var annotations map[string]string if autoscalingRunnerSet.Spec.AutoscalingListenerMetadata != nil { labels = b.filterAndMergeLabels(autoscalingRunnerSet.Spec.AutoscalingListenerMetadata.Labels, labels) annotations = b.mergeAnnotations(autoscalingRunnerSet.Spec.AutoscalingListenerMetadata.Annotations, annotations) } - autoscalingListener := &v1alpha1.AutoscalingListener{ TypeMeta: metav1.TypeMeta{ APIVersion: v1alpha1.GroupVersion.String(), @@ -206,7 +195,7 @@ func (b *ResourceBuilder) newAutoscalingListener(autoscalingRunnerSet *v1alpha1. }, Spec: spec, } - b.ResourceCache.autoscalingListener.Upsert(autoscalingRunnerSet, autoscalingListener, ephemeralRunnerSet, inputDependency) + b.ResourceCache.autoscalingListener.Upsert(autoscalingRunnerSet, autoscalingListener, ephemeralRunnerSet, inputDependency, metadataDependency) return autoscalingListener, nil } @@ -224,6 +213,20 @@ func resourceCacheInputObject(name string, value any) client.Object { } } +func resourceCacheObjectMetadataInputObject(object client.Object) client.Object { + return resourceCacheInputObject(resourceCacheObjectName(object)+"-metadata", struct { + Namespace string + Name string + Labels map[string]string + Annotations map[string]string + }{ + Namespace: object.GetNamespace(), + Name: object.GetName(), + Labels: object.GetLabels(), + Annotations: object.GetAnnotations(), + }) +} + type listenerMetricsServerConfig struct { addr string endpoint string @@ -303,7 +306,6 @@ func (b *ResourceBuilder) newScaleSetListenerConfig(autoscalingListener *v1alpha if autoscalingListener.Spec.ConfigSecretMetadata != nil && len(autoscalingListener.Spec.ConfigSecretMetadata.Annotations) > 0 { annotations = autoscalingListener.Spec.ConfigSecretMetadata.Annotations } - desiredSecret := &corev1.Secret{ TypeMeta: metav1.TypeMeta{ APIVersion: corev1.SchemeGroupVersion.String(), @@ -320,8 +322,6 @@ func (b *ResourceBuilder) newScaleSetListenerConfig(autoscalingListener *v1alpha }, } - desiredSecret.Annotations[annotationKeyIntegrityHash] = scaleSetListenerConfigIntegrityHash(desiredSecret) - if err := b.setControllerReference(autoscalingListener, desiredSecret); err != nil { return nil, fmt.Errorf("failed to set controller reference for listener config secret: %w", err) } @@ -329,18 +329,6 @@ func (b *ResourceBuilder) newScaleSetListenerConfig(autoscalingListener *v1alpha return desiredSecret, nil } -func scaleSetListenerConfigIntegrityHash(secret *corev1.Secret) string { - type data struct { - Data map[string][]byte `json:"data,omitempty"` - } - - d := data{ - Data: secret.Data, - } - - return hash.ComputeTemplateHash(&d) -} - func (b *ResourceBuilder) newScaleSetListenerPod( autoscalingListener *v1alpha1.AutoscalingListener, podConfig *corev1.Secret, @@ -355,7 +343,8 @@ func (b *ResourceBuilder) newScaleSetListenerPod( Namespace: autoscalingListener.Namespace, }, } - if cached, ok := b.ResourceCache.listenerPod.Get(autoscalingListener, cacheKeyObject, podConfig, serviceAccount, role, roleBinding); ok { + metadataDependency := resourceCacheObjectMetadataInputObject(autoscalingListener) + if cached, ok := b.ResourceCache.listenerPod.Get(autoscalingListener, cacheKeyObject, podConfig, serviceAccount, role, roleBinding, metadataDependency); ok { return cached, nil } @@ -478,16 +467,6 @@ func (b *ResourceBuilder) newScaleSetListenerPod( Spec: podSpec, } - newRunnerScaleSetListenerPod.Annotations[annotationKeyIntegrityHash] = scaleSetListenerPodIntegrity( - newRunnerScaleSetListenerPod, - autoscalingListener, - podConfig, - serviceAccount, - role, - roleBinding, - metricsConfig, - ) - if err := b.setControllerReference(autoscalingListener, newRunnerScaleSetListenerPod); err != nil { return nil, fmt.Errorf("failed to set controller reference for listener pod: %w", err) } @@ -495,43 +474,11 @@ func (b *ResourceBuilder) newScaleSetListenerPod( if autoscalingListener.Spec.Template != nil { mergeListenerPodWithTemplate(newRunnerScaleSetListenerPod, autoscalingListener.Spec.Template) } - b.ResourceCache.listenerPod.Upsert(autoscalingListener, newRunnerScaleSetListenerPod, podConfig, serviceAccount, role, roleBinding) + b.ResourceCache.listenerPod.Upsert(autoscalingListener, newRunnerScaleSetListenerPod, podConfig, serviceAccount, role, roleBinding, metadataDependency) return newRunnerScaleSetListenerPod, nil } -func scaleSetListenerPodIntegrity( - pod *corev1.Pod, - autoscalingListener *v1alpha1.AutoscalingListener, - podConfig *corev1.Secret, - serviceAccount *corev1.ServiceAccount, - role *rbacv1.Role, - roleBinding *rbacv1.RoleBinding, - metricsConfig *listenerMetricsServerConfig, -) string { - type data struct { - ListenerPodSpec *corev1.PodSpec `json:"listenerPodSpec,omitempty"` - AutoscalingListenerIntegrityHash string `json:"autoscalingListenerIntegrityHash"` - ConfigSecretIntegrityHash string `json:"configSecretIntegrityHash"` - ServiceAccountIntegrityHash string `json:"serviceAccountIntegrityHash"` - RoleIntegrityHash string `json:"roleIntegrityHash"` - RoleBindingIntegrityHash string `json:"roleBindingIntegrityHash"` - MetricsConfig *listenerMetricsServerConfig `json:"metricsConfig,omitempty"` - } - - d := data{ - ListenerPodSpec: &pod.Spec, - AutoscalingListenerIntegrityHash: autoscalingListener.Annotations[annotationKeyIntegrityHash], - ConfigSecretIntegrityHash: podConfig.Annotations[annotationKeyIntegrityHash], - ServiceAccountIntegrityHash: serviceAccount.Annotations[annotationKeyIntegrityHash], - RoleIntegrityHash: role.Annotations[annotationKeyIntegrityHash], - RoleBindingIntegrityHash: roleBinding.Annotations[annotationKeyIntegrityHash], - MetricsConfig: metricsConfig, - } - - return hash.ComputeTemplateHash(&d) -} - func mergeListenerPodWithTemplate(pod *corev1.Pod, tmpl *corev1.PodTemplateSpec) { if pod.Annotations == nil { pod.Annotations = make(map[string]string) @@ -656,7 +603,8 @@ func (b *ResourceBuilder) newScaleSetListenerServiceAccount(autoscalingListener Namespace: autoscalingListener.Namespace, }, } - if cached, ok := b.ResourceCache.listenerServiceAccount.Get(autoscalingListener, cacheKeyObject); ok { + metadataDependency := resourceCacheObjectMetadataInputObject(autoscalingListener) + if cached, ok := b.ResourceCache.listenerServiceAccount.Get(autoscalingListener, cacheKeyObject, metadataDependency); ok { return cached, nil } @@ -680,33 +628,14 @@ func (b *ResourceBuilder) newScaleSetListenerServiceAccount(autoscalingListener base.Labels = b.filterAndMergeLabels(autoscalingListener.Spec.ServiceAccountMetadata.Labels, base.Labels) base.Annotations = b.mergeAnnotations(autoscalingListener.Spec.ServiceAccountMetadata.Annotations, base.Annotations) } - - base.Annotations[annotationKeyIntegrityHash] = scaleSetListenerServiceAccountIntegrityHash(base) - if err := b.setControllerReference(autoscalingListener, base); err != nil { return nil, fmt.Errorf("failed to set controller reference for listener service account: %w", err) } - b.ResourceCache.listenerServiceAccount.Upsert(autoscalingListener, base) + b.ResourceCache.listenerServiceAccount.Upsert(autoscalingListener, base, metadataDependency) return base, nil } -func scaleSetListenerServiceAccountIntegrityHash(sa *corev1.ServiceAccount) string { - type data struct { - Secrets []corev1.ObjectReference `json:"secrets"` - ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets"` - AutomountServiceAccountToken *bool `json:"automountServiceAccountToken"` - } - - d := data{ - Secrets: sa.Secrets, - ImagePullSecrets: sa.ImagePullSecrets, - AutomountServiceAccountToken: sa.AutomountServiceAccountToken, - } - - return hash.ComputeTemplateHash(&d) -} - func (b *ResourceBuilder) newScaleSetListenerRole(autoscalingListener *v1alpha1.AutoscalingListener) *rbacv1.Role { cacheKeyObject := &rbacv1.Role{ ObjectMeta: metav1.ObjectMeta{ @@ -714,7 +643,8 @@ func (b *ResourceBuilder) newScaleSetListenerRole(autoscalingListener *v1alpha1. Namespace: autoscalingListener.Spec.AutoscalingRunnerSetNamespace, }, } - if cached, ok := b.ResourceCache.listenerRole.Get(autoscalingListener, cacheKeyObject); ok { + metadataDependency := resourceCacheObjectMetadataInputObject(autoscalingListener) + if cached, ok := b.ResourceCache.listenerRole.Get(autoscalingListener, cacheKeyObject, metadataDependency); ok { return cached } @@ -730,7 +660,6 @@ func (b *ResourceBuilder) newScaleSetListenerRole(autoscalingListener *v1alpha1. labels = b.filterAndMergeLabels(autoscalingListener.Spec.RoleMetadata.Labels, labels) annotations = b.mergeAnnotations(autoscalingListener.Spec.RoleMetadata.Annotations, nil) } - newRole := &rbacv1.Role{ TypeMeta: metav1.TypeMeta{ APIVersion: rbacv1.SchemeGroupVersion.String(), @@ -745,24 +674,11 @@ func (b *ResourceBuilder) newScaleSetListenerRole(autoscalingListener *v1alpha1. Rules: rulesForListenerRole([]string{autoscalingListener.Spec.EphemeralRunnerSetName}), } - newRole.Annotations[annotationKeyIntegrityHash] = scaleSetRoleIntegrityHash(newRole) - b.ResourceCache.listenerRole.Upsert(autoscalingListener, newRole) + b.ResourceCache.listenerRole.Upsert(autoscalingListener, newRole, metadataDependency) return newRole } -func scaleSetRoleIntegrityHash(role *rbacv1.Role) string { - type data struct { - Rules []rbacv1.PolicyRule `json:"rules"` - } - - d := data{ - Rules: role.Rules, - } - - return hash.ComputeTemplateHash(&d) -} - func (b *ResourceBuilder) newScaleSetListenerRoleBinding(autoscalingListener *v1alpha1.AutoscalingListener, listenerRole *rbacv1.Role, serviceAccount *corev1.ServiceAccount) *rbacv1.RoleBinding { cacheKeyObject := &rbacv1.RoleBinding{ ObjectMeta: metav1.ObjectMeta{ @@ -770,7 +686,8 @@ func (b *ResourceBuilder) newScaleSetListenerRoleBinding(autoscalingListener *v1 Namespace: autoscalingListener.Spec.AutoscalingRunnerSetNamespace, }, } - if cached, ok := b.ResourceCache.listenerRoleBinding.Get(autoscalingListener, cacheKeyObject, listenerRole, serviceAccount); ok { + metadataDependency := resourceCacheObjectMetadataInputObject(autoscalingListener) + if cached, ok := b.ResourceCache.listenerRoleBinding.Get(autoscalingListener, cacheKeyObject, listenerRole, serviceAccount, metadataDependency); ok { return cached } @@ -799,7 +716,6 @@ func (b *ResourceBuilder) newScaleSetListenerRoleBinding(autoscalingListener *v1 labels = b.filterAndMergeLabels(autoscalingListener.Spec.RoleBindingMetadata.Labels, labels) annotations = autoscalingListener.Spec.RoleBindingMetadata.Annotations } - newRoleBinding := &rbacv1.RoleBinding{ TypeMeta: metav1.TypeMeta{ APIVersion: rbacv1.SchemeGroupVersion.String(), @@ -815,26 +731,11 @@ func (b *ResourceBuilder) newScaleSetListenerRoleBinding(autoscalingListener *v1 Subjects: subjects, } - newRoleBinding.Annotations[annotationKeyIntegrityHash] = scaleSetListenerRoleBindingIntegrityHash(newRoleBinding) - b.ResourceCache.listenerRoleBinding.Upsert(autoscalingListener, newRoleBinding, listenerRole, serviceAccount) + b.ResourceCache.listenerRoleBinding.Upsert(autoscalingListener, newRoleBinding, listenerRole, serviceAccount, metadataDependency) return newRoleBinding } -func scaleSetListenerRoleBindingIntegrityHash(rb *rbacv1.RoleBinding) string { - type data struct { - RoleRef rbacv1.RoleRef `json:"roleRef"` - Subjects []rbacv1.Subject `json:"subjects"` - } - - d := data{ - RoleRef: rb.RoleRef, - Subjects: rb.Subjects, - } - - return hash.ComputeTemplateHash(&d) -} - func (b *ResourceBuilder) newEphemeralRunnerSet(autoscalingRunnerSet *v1alpha1.AutoscalingRunnerSet) (*v1alpha1.EphemeralRunnerSet, error) { runnerScaleSetID, err := strconv.Atoi(autoscalingRunnerSet.Annotations[runnerScaleSetIDAnnotationKey]) if err != nil { @@ -847,7 +748,8 @@ func (b *ResourceBuilder) newEphemeralRunnerSet(autoscalingRunnerSet *v1alpha1.A Namespace: autoscalingRunnerSet.Namespace, }, } - if cached, ok := b.ResourceCache.ephemeralRunnerSet.Get(autoscalingRunnerSet, cacheKeyObject); ok { + metadataDependency := resourceCacheObjectMetadataInputObject(autoscalingRunnerSet) + if cached, ok := b.ResourceCache.ephemeralRunnerSet.Get(autoscalingRunnerSet, cacheKeyObject, metadataDependency); ok { return cached, nil } @@ -887,7 +789,6 @@ func (b *ResourceBuilder) newEphemeralRunnerSet(autoscalingRunnerSet *v1alpha1.A labels = b.filterAndMergeLabels(autoscalingRunnerSet.Spec.EphemeralRunnerSetMetadata.Labels, labels) annotations = b.mergeAnnotations(autoscalingRunnerSet.Spec.EphemeralRunnerSetMetadata.Annotations, annotations) } - newEphemeralRunnerSet := &v1alpha1.EphemeralRunnerSet{ TypeMeta: metav1.TypeMeta{ APIVersion: v1alpha1.GroupVersion.String(), @@ -902,27 +803,14 @@ func (b *ResourceBuilder) newEphemeralRunnerSet(autoscalingRunnerSet *v1alpha1.A Spec: spec, } - newEphemeralRunnerSet.Annotations[annotationKeyIntegrityHash] = ephemeralRunnerSetIntegrityHash(newEphemeralRunnerSet) - if err := b.setControllerReference(autoscalingRunnerSet, newEphemeralRunnerSet); err != nil { return nil, fmt.Errorf("failed to set controller reference for ephemeral runner set: %w", err) } - b.ResourceCache.ephemeralRunnerSet.Upsert(autoscalingRunnerSet, newEphemeralRunnerSet) + b.ResourceCache.ephemeralRunnerSet.Upsert(autoscalingRunnerSet, newEphemeralRunnerSet, metadataDependency) return newEphemeralRunnerSet, nil } -func ephemeralRunnerSetIntegrityHash(ers *v1alpha1.EphemeralRunnerSet) string { - type data struct { - EphemeralRunnerSpec v1alpha1.EphemeralRunnerSpec `json:"ephemeralRunnerSpec"` - } - - d := data{ - EphemeralRunnerSpec: ers.Spec.EphemeralRunnerSpec, - } - return hash.ComputeTemplateHash(&d) -} - func (b *ResourceBuilder) newAutoscalingListenerProxySecret(autoscalingListener *v1alpha1.AutoscalingListener, data map[string][]byte) (*corev1.Secret, error) { newProxySecret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -937,8 +825,6 @@ func (b *ResourceBuilder) newAutoscalingListenerProxySecret(autoscalingListener Data: data, } - newProxySecret.Annotations[annotationKeyIntegrityHash] = autoscalingListenerProxySecretIntegrityHash(newProxySecret) - if err := b.setControllerReference(autoscalingListener, newProxySecret); err != nil { return nil, fmt.Errorf("failed to set controller reference for listener proxy secret: %w", err) } @@ -946,18 +832,6 @@ func (b *ResourceBuilder) newAutoscalingListenerProxySecret(autoscalingListener return newProxySecret, nil } -func autoscalingListenerProxySecretIntegrityHash(secret *corev1.Secret) string { - type data struct { - Data map[string][]byte `json:"data"` - } - - d := data{ - Data: secret.Data, - } - - return hash.ComputeTemplateHash(&d) -} - func (b *ResourceBuilder) newEphemeralRunner(ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet) (*v1alpha1.EphemeralRunner, error) { labels := make(map[string]string, len(ephemeralRunnerSet.Labels)) maps.Copy(labels, ephemeralRunnerSet.Labels) @@ -971,7 +845,6 @@ func (b *ResourceBuilder) newEphemeralRunner(ephemeralRunnerSet *v1alpha1.Epheme labels = b.filterAndMergeLabels(ephemeralRunnerSet.Spec.EphemeralRunnerMetadata.Labels, labels) annotations = b.mergeAnnotations(ephemeralRunnerSet.Spec.EphemeralRunnerMetadata.Annotations, annotations) } - ephemeralRunner := &v1alpha1.EphemeralRunner{ ObjectMeta: metav1.ObjectMeta{ GenerateName: ephemeralRunnerSet.Name + "-runner-", @@ -998,7 +871,6 @@ func (b *ResourceBuilder) newEphemeralRunnerPod(runner *v1alpha1.EphemeralRunner annotations := make(map[string]string, len(runner.Annotations)+len(runner.Spec.Annotations)) maps.Copy(annotations, runner.Annotations) maps.Copy(annotations, runner.Spec.Annotations) - labels := make(map[string]string, len(runner.Labels)+len(runner.Spec.Labels)+2) maps.Copy(labels, runner.Labels) maps.Copy(labels, runner.Spec.Labels) @@ -1068,7 +940,6 @@ func (b *ResourceBuilder) newEphemeralRunnerJitSecret(ephemeralRunner *v1alpha1. labels = b.filterAndMergeLabels(ephemeralRunner.Spec.EphemeralRunnerConfigSecretMetadata.Labels, nil) annotations = ephemeralRunner.Spec.EphemeralRunnerConfigSecretMetadata.Annotations } - jitSecret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: ephemeralRunner.Name, @@ -1104,8 +975,6 @@ func (b *ResourceBuilder) newEphemeralRunnerSetProxySecret(ephemeralRunnerSet *v Data: data, } - runnerPodProxySecret.Annotations[annotationKeyIntegrityHash] = ephemeralRunnerSetProxySecretZIdentityHash(runnerPodProxySecret) - if err := b.setControllerReference(ephemeralRunnerSet, runnerPodProxySecret); err != nil { return nil, fmt.Errorf("failed to set controller reference for ephemeral runner set proxy secret: %w", err) } @@ -1113,18 +982,6 @@ func (b *ResourceBuilder) newEphemeralRunnerSetProxySecret(ephemeralRunnerSet *v return runnerPodProxySecret, nil } -func ephemeralRunnerSetProxySecretZIdentityHash(secret *corev1.Secret) string { - type data struct { - Data map[string][]byte `json:"data"` - } - - d := data{ - Data: secret.Data, - } - - return hash.ComputeTemplateHash(&d) -} - func scaleSetListenerConfigName(autoscalingListener *v1alpha1.AutoscalingListener) string { return autoscalingListener.Name + "-config" } diff --git a/controllers/actions.github.com/resourcebuilder_test.go b/controllers/actions.github.com/resourcebuilder_test.go index 6097308d71..e71d079ae8 100644 --- a/controllers/actions.github.com/resourcebuilder_test.go +++ b/controllers/actions.github.com/resourcebuilder_test.go @@ -115,7 +115,6 @@ func TestMetadataPropagation(t *testing.T) { assert.Equal(t, labelValueKubernetesPartOf, ephemeralRunnerSet.Labels[LabelKeyKubernetesPartOf]) assert.Equal(t, "runner-set", ephemeralRunnerSet.Labels[LabelKeyKubernetesComponent]) assert.Equal(t, autoscalingRunnerSet.Labels[LabelKeyKubernetesVersion], ephemeralRunnerSet.Labels[LabelKeyKubernetesVersion]) - assert.NotEmpty(t, ephemeralRunnerSet.Annotations[annotationKeyIntegrityHash]) assert.Equal(t, autoscalingRunnerSet.Name, ephemeralRunnerSet.Labels[LabelKeyGitHubScaleSetName]) assert.Equal(t, autoscalingRunnerSet.Namespace, ephemeralRunnerSet.Labels[LabelKeyGitHubScaleSetNamespace]) assert.Equal(t, "", ephemeralRunnerSet.Labels[LabelKeyGitHubEnterprise]) @@ -132,7 +131,6 @@ func TestMetadataPropagation(t *testing.T) { assert.Equal(t, labelValueKubernetesPartOf, listener.Labels[LabelKeyKubernetesPartOf]) assert.Equal(t, "runner-scale-set-listener", listener.Labels[LabelKeyKubernetesComponent]) assert.Equal(t, autoscalingRunnerSet.Labels[LabelKeyKubernetesVersion], listener.Labels[LabelKeyKubernetesVersion]) - assert.NotEmpty(t, ephemeralRunnerSet.Annotations[annotationKeyIntegrityHash]) assert.Equal(t, autoscalingRunnerSet.Name, listener.Labels[LabelKeyGitHubScaleSetName]) assert.Equal(t, autoscalingRunnerSet.Namespace, listener.Labels[LabelKeyGitHubScaleSetNamespace]) assert.Equal(t, "", listener.Labels[LabelKeyGitHubEnterprise]) @@ -206,33 +204,6 @@ func TestMetadataPropagation(t *testing.T) { } } -func TestEphemeralRunnerSetProxySecretZIdentityHash(t *testing.T) { - ephemeralRunnerSet := &v1alpha1.EphemeralRunnerSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-scale-set", - Namespace: "test-ns", - Labels: map[string]string{ - LabelKeyGitHubScaleSetName: "test-scale-set", - LabelKeyGitHubScaleSetNamespace: "test-ns", - }, - }, - } - - var b ResourceBuilder - proxySecret, err := b.newEphemeralRunnerSetProxySecret(ephemeralRunnerSet, map[string][]byte{ - "http_proxy": []byte("http://proxy.example.com"), - }) - require.NoError(t, err) - - actualHash := proxySecret.Annotations[annotationKeyIntegrityHash] - assert.NotEmpty(t, actualHash) - assert.Equal(t, ephemeralRunnerSetProxySecretZIdentityHash(proxySecret), actualHash) - - changedProxySecret := proxySecret.DeepCopy() - changedProxySecret.Data["http_proxy"] = []byte("http://updated-proxy.example.com") - assert.NotEqual(t, actualHash, ephemeralRunnerSetProxySecretZIdentityHash(changedProxySecret)) -} - func TestGitHubURLTrimLabelValues(t *testing.T) { enterprise := strings.Repeat("a", 64) organization := strings.Repeat("b", 64) @@ -318,7 +289,6 @@ func TestOwnershipRelationships(t *testing.T) { runnerScaleSetIDAnnotationKey: "1", AnnotationKeyGitHubRunnerGroupName: "test-group", AnnotationKeyGitHubRunnerScaleSetName: "test-scale-set", - annotationKeyIntegrityHash: "test-hash", }, }, Spec: v1alpha1.AutoscalingRunnerSetSpec{ diff --git a/controllers/actions.github.com/resourcecache.go b/controllers/actions.github.com/resourcecache.go index 2a4b1c1035..ec99c6bce5 100644 --- a/controllers/actions.github.com/resourcecache.go +++ b/controllers/actions.github.com/resourcecache.go @@ -19,7 +19,7 @@ const ( resourceCacheInitialEntries = 4096 resourceCacheInitialMainUIDEntries = 4096 resourceCacheInitialOwnerEntries = 8 - resourceCacheMaxDependencyRefs = 4 + resourceCacheMaxDependencyRefs = 5 ) type ResourceCacheObjectRef struct { @@ -28,6 +28,7 @@ type ResourceCacheObjectRef struct { Name string UID types.UID ResourceVersion string + Generation int64 // Used for CR owner identity (main objects), zero for dependencies/desired objects } type ResourceCacheKey struct { @@ -99,11 +100,15 @@ func (s *resourceCacheState[T]) Get( } key := newResourceCacheKey(mainObject, desiredObject) - mainObjectRef := newResourceCacheObjectRef(mainObject) + mainObjectRef := newResourceCacheMainObjectRef(mainObject) + resourceVersion := desiredObject.GetResourceVersion() + if resourceVersion == "" && !isResourceCacheLookupObject(desiredObject) { + resourceVersion = hash.ComputeTemplateHash(desiredObject) + } s.mu.RLock() value, ok := s.entries[key] - if ok && value.MainObject == mainObjectRef && value.dependencyKey.Equal(dependencyKey) { + if ok && value.MainObject == mainObjectRef && (resourceVersion == "" || value.ResourceVersion == resourceVersion) && value.dependencyKey.Equal(dependencyKey) { s.mu.RUnlock() return value.Object, true } @@ -130,8 +135,11 @@ func (s *resourceCacheState[T]) Upsert( } key := newResourceCacheKey(mainObject, desiredObject) - mainObjectRef := newResourceCacheObjectRef(mainObject) + mainObjectRef := newResourceCacheMainObjectRef(mainObject) resourceVersion := desiredObject.GetResourceVersion() + if resourceVersion == "" { + resourceVersion = hash.ComputeTemplateHash(desiredObject) + } s.mu.RLock() previous, ok := s.entries[key] @@ -219,7 +227,7 @@ func newResourceCacheDependencyKey(objects ...client.Object) (resourceCacheDepen if isNilResourceCacheObject(object) { return resourceCacheDependencyKey{}, false } - key.refs[i] = newResourceCacheObjectRef(object) + key.refs[i] = newResourceCacheDependencyObjectRef(object) } slices.SortFunc(key.refs[:key.count], func(a, b ResourceCacheObjectRef) int { return compareResourceCacheObjectRefs(a, b) @@ -244,11 +252,18 @@ func (k resourceCacheDependencyKey) Equal(other resourceCacheDependencyKey) bool return true } -func newResourceCacheObjectRef(object client.Object) ResourceCacheObjectRef { - resourceVersion := object.GetResourceVersion() - if resourceVersion == "" { - resourceVersion = object.GetAnnotations()[annotationKeyIntegrityHash] +func newResourceCacheMainObjectRef(object client.Object) ResourceCacheObjectRef { + return ResourceCacheObjectRef{ + ObjectType: object.GetObjectKind().GroupVersionKind(), + Namespace: object.GetNamespace(), + Name: resourceCacheObjectName(object), + UID: object.GetUID(), + Generation: object.GetGeneration(), } +} + +func newResourceCacheDependencyObjectRef(object client.Object) ResourceCacheObjectRef { + resourceVersion := object.GetResourceVersion() if resourceVersion == "" { resourceVersion = hash.ComputeTemplateHash(object) } @@ -275,6 +290,12 @@ func compareResourceCacheObjectRefs(a, b ResourceCacheObjectRef) int { if c := strings.Compare(string(a.UID), string(b.UID)); c != 0 { return c } + if a.Generation != b.Generation { + if a.Generation < b.Generation { + return -1 + } + return 1 + } return strings.Compare(a.ResourceVersion, b.ResourceVersion) } @@ -304,3 +325,25 @@ func isNilResourceCacheObject[T client.Object](object T) bool { value := reflect.ValueOf(clientObject) return value.Kind() == reflect.Pointer && value.IsNil() } + +func isResourceCacheLookupObject(object client.Object) bool { + lookupObject, ok := object.DeepCopyObject().(client.Object) + if !ok { + return false + } + lookupObject.SetGenerateName(object.GetGenerateName()) + lookupObject.SetName("") + lookupObject.SetNamespace("") + lookupObject.SetResourceVersion("") + + objectValue := reflect.ValueOf(object) + if objectValue.Kind() != reflect.Pointer { + return false + } + zeroObject, ok := reflect.New(objectValue.Elem().Type()).Interface().(client.Object) + if !ok { + return false + } + + return hash.ComputeTemplateHash(lookupObject) == hash.ComputeTemplateHash(zeroObject) +} diff --git a/controllers/actions.github.com/resourcecache_test.go b/controllers/actions.github.com/resourcecache_test.go index 608ed9d95d..aa54341a1d 100644 --- a/controllers/actions.github.com/resourcecache_test.go +++ b/controllers/actions.github.com/resourcecache_test.go @@ -184,12 +184,10 @@ func TestResourceCacheIgnoresInvalidInputs(t *testing.T) { func TestResourceBuilderCachesListenerPodDependencies(t *testing.T) { listener := &v1alpha1.AutoscalingListener{ ObjectMeta: metav1.ObjectMeta{ - Name: "listener", - Namespace: "controller-ns", - UID: "listener-uid", - Annotations: map[string]string{ - annotationKeyIntegrityHash: "listener-hash", - }, + Name: "listener", + Namespace: "controller-ns", + UID: "listener-uid", + Annotations: map[string]string{"example.com/listener-hash": "listener-hash"}, }, Spec: v1alpha1.AutoscalingListenerSpec{ Image: "listener:latest", @@ -204,9 +202,7 @@ func TestResourceBuilderCachesListenerPodDependencies(t *testing.T) { Namespace: "controller-ns", UID: "config-secret-uid", ResourceVersion: "11", - Annotations: map[string]string{ - annotationKeyIntegrityHash: "config-hash", - }, + Annotations: map[string]string{"example.com/config-hash": "config-hash"}, }, } serviceAccount := &corev1.ServiceAccount{ @@ -215,9 +211,7 @@ func TestResourceBuilderCachesListenerPodDependencies(t *testing.T) { Namespace: "controller-ns", UID: "service-account-uid", ResourceVersion: "12", - Annotations: map[string]string{ - annotationKeyIntegrityHash: "service-account-hash", - }, + Annotations: map[string]string{"example.com/service-account-hash": "service-account-hash"}, }, } role := &rbacv1.Role{ @@ -226,9 +220,7 @@ func TestResourceBuilderCachesListenerPodDependencies(t *testing.T) { Namespace: "scale-set-ns", UID: "role-uid", ResourceVersion: "13", - Annotations: map[string]string{ - annotationKeyIntegrityHash: "role-hash", - }, + Annotations: map[string]string{"example.com/role-hash": "role-hash"}, }, } roleBinding := &rbacv1.RoleBinding{ @@ -237,9 +229,7 @@ func TestResourceBuilderCachesListenerPodDependencies(t *testing.T) { Namespace: "scale-set-ns", UID: "role-binding-uid", ResourceVersion: "14", - Annotations: map[string]string{ - annotationKeyIntegrityHash: "role-binding-hash", - }, + Annotations: map[string]string{"example.com/role-binding-hash": "role-binding-hash"}, }, } @@ -248,15 +238,62 @@ func TestResourceBuilderCachesListenerPodDependencies(t *testing.T) { listenerPod, err := b.newScaleSetListenerPod(listener, podConfig, serviceAccount, role, roleBinding, nil) require.NoError(t, err) - cachedPod, ok := b.ResourceCache.listenerPod.Get(listener, listenerPod, podConfig, serviceAccount, role, roleBinding) + metadataDependency := resourceCacheObjectMetadataInputObject(listener) + cachedPod, ok := b.ResourceCache.listenerPod.Get(listener, listenerPod, podConfig, serviceAccount, role, roleBinding, metadataDependency) require.True(t, ok) assert.IsType(t, &corev1.Pod{}, cachedPod) + lookupPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: listenerPod.Name, + Namespace: listenerPod.Namespace, + }, + } + cachedPod, ok = b.ResourceCache.listenerPod.Get(listener, lookupPod, podConfig, serviceAccount, role, roleBinding, metadataDependency) + require.True(t, ok, "name-only lookup object should hit the cached desired pod") + assert.Same(t, listenerPod, cachedPod) + role.ResourceVersion = "changed" - _, ok = b.ResourceCache.listenerPod.Get(listener, listenerPod, podConfig, serviceAccount, role, roleBinding) + _, ok = b.ResourceCache.listenerPod.Get(listener, lookupPod, podConfig, serviceAccount, role, roleBinding, metadataDependency) assert.False(t, ok) } +func TestResourceBuilderCachesListenerPodMetadataDependency(t *testing.T) { + listener := &v1alpha1.AutoscalingListener{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "controller-ns", + UID: "listener-uid", + Labels: map[string]string{ + "arc.test/listener-label": "initial", + }, + }, + Spec: v1alpha1.AutoscalingListenerSpec{ + Image: "listener:latest", + AutoscalingRunnerSetName: "scale-set", + AutoscalingRunnerSetNamespace: "scale-set-ns", + EphemeralRunnerSetName: "scale-set", + }, + } + podConfig := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "listener-config", Namespace: "controller-ns", UID: "config-secret-uid", ResourceVersion: "11"}} + serviceAccount := &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "listener", Namespace: "controller-ns", UID: "service-account-uid", ResourceVersion: "12"}} + role := &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: "listener", Namespace: "scale-set-ns", UID: "role-uid", ResourceVersion: "13"}} + roleBinding := &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "listener", Namespace: "scale-set-ns", UID: "role-binding-uid", ResourceVersion: "14"}} + + cache := NewResourceCache() + b := ResourceBuilder{ResourceCache: &cache} + listenerPod, err := b.newScaleSetListenerPod(listener, podConfig, serviceAccount, role, roleBinding, nil) + require.NoError(t, err) + + lookupPod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: listenerPod.Name, Namespace: listenerPod.Namespace}} + _, ok := b.ResourceCache.listenerPod.Get(listener, lookupPod, podConfig, serviceAccount, role, roleBinding, resourceCacheObjectMetadataInputObject(listener)) + assert.True(t, ok) + + listener.Labels["arc.test/listener-label"] = "updated" + _, ok = b.ResourceCache.listenerPod.Get(listener, lookupPod, podConfig, serviceAccount, role, roleBinding, resourceCacheObjectMetadataInputObject(listener)) + assert.False(t, ok, "cache miss when listener metadata used by the pod changes") +} + func TestResourceBuilderCachesEphemeralRunnerSet(t *testing.T) { autoscalingRunnerSet := v1alpha1.AutoscalingRunnerSet{ ObjectMeta: metav1.ObjectMeta{ @@ -277,16 +314,184 @@ func TestResourceBuilderCachesEphemeralRunnerSet(t *testing.T) { runnerSet, err := b.newEphemeralRunnerSet(&autoscalingRunnerSet) require.NoError(t, err) - cachedRunnerSet, ok := b.ResourceCache.ephemeralRunnerSet.Get(&autoscalingRunnerSet, runnerSet) - require.True(t, ok) + metadataDependency := resourceCacheObjectMetadataInputObject(&autoscalingRunnerSet) + cachedRunnerSet, ok := b.ResourceCache.ephemeralRunnerSet.Get(&autoscalingRunnerSet, runnerSet, metadataDependency) + require.True(t, ok, "direct cache Get with returned object should hit") assert.Equal(t, runnerSet.Spec, cachedRunnerSet.Spec) assert.Same(t, runnerSet, cachedRunnerSet) - fromBuilder, err := b.newEphemeralRunnerSet(&autoscalingRunnerSet) - require.NoError(t, err) - assert.Same(t, runnerSet, fromBuilder) + lookupRunnerSet := &v1alpha1.EphemeralRunnerSet{ObjectMeta: metav1.ObjectMeta{Name: runnerSet.Name, Namespace: runnerSet.Namespace}} + cachedRunnerSet, ok = b.ResourceCache.ephemeralRunnerSet.Get(&autoscalingRunnerSet, lookupRunnerSet, metadataDependency) + require.True(t, ok, "name-only lookup object should hit the cached desired runner set") + assert.Same(t, runnerSet, cachedRunnerSet) autoscalingRunnerSet.Annotations[runnerScaleSetIDAnnotationKey] = "2" - _, ok = b.ResourceCache.ephemeralRunnerSet.Get(&autoscalingRunnerSet, runnerSet) - assert.False(t, ok) + _, ok = b.ResourceCache.ephemeralRunnerSet.Get(&autoscalingRunnerSet, lookupRunnerSet, metadataDependency) + assert.True(t, ok, "cache should be valid when main object generation unchanged") +} + +func TestResourceBuilderCachesEphemeralRunnerSetMetadataDependency(t *testing.T) { + autoscalingRunnerSet := v1alpha1.AutoscalingRunnerSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "scale-set", + Namespace: "default", + UID: "scale-set-uid", + Labels: map[string]string{ + "arc.test/scale-set-label": "initial", + }, + Annotations: map[string]string{ + runnerScaleSetIDAnnotationKey: "1", + }, + }, + Spec: v1alpha1.AutoscalingRunnerSetSpec{ + GitHubConfigUrl: "https://github.com/actions/actions-runner-controller", + }, + } + + cache := NewResourceCache() + b := ResourceBuilder{ResourceCache: &cache} + runnerSet, err := b.newEphemeralRunnerSet(&autoscalingRunnerSet) + require.NoError(t, err) + + lookupRunnerSet := &v1alpha1.EphemeralRunnerSet{ObjectMeta: metav1.ObjectMeta{Name: runnerSet.Name, Namespace: runnerSet.Namespace}} + _, ok := b.ResourceCache.ephemeralRunnerSet.Get(&autoscalingRunnerSet, lookupRunnerSet, resourceCacheObjectMetadataInputObject(&autoscalingRunnerSet)) + assert.True(t, ok) + + autoscalingRunnerSet.Labels["arc.test/scale-set-label"] = "updated" + _, ok = b.ResourceCache.ephemeralRunnerSet.Get(&autoscalingRunnerSet, lookupRunnerSet, resourceCacheObjectMetadataInputObject(&autoscalingRunnerSet)) + assert.False(t, ok, "cache miss when autoscaling runner set metadata used by the runner set changes") +} + +func TestResourceCacheOwnerGenerationDoesNotAffectCacheEntry(t *testing.T) { + mainObject := &v1alpha1.AutoscalingListener{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "controller-ns", + UID: "listener-uid", + Generation: 5, + }, + } + desiredPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "controller-ns", + }, + } + + cache := NewResourceCache() + _, replaced := cache.listenerPod.Upsert(mainObject, desiredPod) + assert.True(t, replaced) + _, ok := cache.listenerPod.Get(mainObject, desiredPod) + assert.True(t, ok) + + mainObjectCopy := mainObject.DeepCopy() + _, ok = cache.listenerPod.Get(mainObjectCopy, desiredPod) + assert.True(t, ok, "cache hit when owner generation unchanged") +} + +func TestResourceCacheOwnerGenerationChangeInvalidatesCacheEntry(t *testing.T) { + mainObject := &v1alpha1.AutoscalingListener{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "controller-ns", + UID: "listener-uid", + Generation: 5, + }, + } + desiredPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "controller-ns", + }, + } + + cache := NewResourceCache() + _, replaced := cache.listenerPod.Upsert(mainObject, desiredPod) + assert.True(t, replaced) + _, ok := cache.listenerPod.Get(mainObject, desiredPod) + assert.True(t, ok) + + mainObjectWithNewGeneration := mainObject.DeepCopy() + mainObjectWithNewGeneration.Generation = 6 + _, ok = cache.listenerPod.Get(mainObjectWithNewGeneration, desiredPod) + assert.False(t, ok, "cache miss when owner generation changes") +} + +func TestResourceCacheDependencyResourceVersionChangeInvalidates(t *testing.T) { + mainObject := &v1alpha1.AutoscalingListener{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "controller-ns", + UID: "listener-uid", + }, + } + desiredPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "controller-ns", + }, + } + dependency := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "config", + Namespace: "controller-ns", + UID: "config-uid", + ResourceVersion: "1", + }, + } + + cache := NewResourceCache() + _, replaced := cache.listenerPod.Upsert(mainObject, desiredPod, dependency) + assert.True(t, replaced) + _, ok := cache.listenerPod.Get(mainObject, desiredPod, dependency) + assert.True(t, ok) + + dependencyWithNewResourceVersion := dependency.DeepCopy() + dependencyWithNewResourceVersion.ResourceVersion = "2" + _, ok = cache.listenerPod.Get(mainObject, desiredPod, dependencyWithNewResourceVersion) + assert.False(t, ok, "cache miss when dependency resourceVersion changes") +} + +func TestResourceCacheNoAnnotationFallback(t *testing.T) { + mainObject := &v1alpha1.AutoscalingListener{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "controller-ns", + UID: "listener-uid", + }, + } + desiredPodWithoutResourceVersion := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "controller-ns", + }, + } + desiredPodWithDifferentAnnotation := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "controller-ns", + Annotations: map[string]string{ + "unrelated-key": "unrelated-value", + }, + }, + } + + cache := NewResourceCache() + _, replaced := cache.listenerPod.Upsert(mainObject, desiredPodWithoutResourceVersion) + assert.True(t, replaced) + + _, ok := cache.listenerPod.Get(mainObject, desiredPodWithoutResourceVersion) + assert.True(t, ok, "cache hit with same pod object") + + _, ok = cache.listenerPod.Get(mainObject, desiredPodWithDifferentAnnotation) + assert.False(t, ok, "cache miss when pod changed - uses hash not annotation fallback") + + desiredPodIdentical := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "listener", + Namespace: "controller-ns", + }, + } + _, ok = cache.listenerPod.Get(mainObject, desiredPodIdentical) + assert.True(t, ok, "cache hit when pod structure identical even if different instance") } From daca163ca10da766678c6504a121ecd8d638f57e Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Wed, 22 Jul 2026 14:48:54 +0200 Subject: [PATCH 24/31] wip --- .github/workflows/arc-publish-chart.yaml | 2 +- .github/workflows/arc-validate-chart.yaml | 2 +- .github/workflows/gha-publish-chart.yaml | 16 +- .github/workflows/gha-validate-chart.yaml | 4 +- .github/workflows/global-publish-canary.yaml | 8 +- .github/workflows/go.yaml | 2 +- .../autoscalinglistener_controller.go | 18 +- .../autoscalinglistener_controller_test.go | 194 ++++---------- .../ephemeralrunner_controller.go | 44 ++-- controllers/actions.github.com/helpers.go | 24 -- .../actions.github.com/helpers_test.go | 197 -------------- .../actions.github.com/resourcebuilder.go | 53 ++-- .../resourcebuilder_test.go | 27 ++ .../actions.github.com/resourcecache.go | 60 +---- .../actions.github.com/resourcecache_test.go | 244 +----------------- 15 files changed, 161 insertions(+), 734 deletions(-) diff --git a/.github/workflows/arc-publish-chart.yaml b/.github/workflows/arc-publish-chart.yaml index 5d1983a246..9e4fde5ada 100644 --- a/.github/workflows/arc-publish-chart.yaml +++ b/.github/workflows/arc-publish-chart.yaml @@ -45,7 +45,7 @@ jobs: fetch-depth: 0 - name: Set up Helm - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 + uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 with: version: ${{ env.HELM_VERSION }} diff --git a/.github/workflows/arc-validate-chart.yaml b/.github/workflows/arc-validate-chart.yaml index e9ea3ba0dc..f905306c5a 100644 --- a/.github/workflows/arc-validate-chart.yaml +++ b/.github/workflows/arc-validate-chart.yaml @@ -45,7 +45,7 @@ jobs: fetch-depth: 0 - name: Set up Helm - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 + uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 with: version: ${{ env.HELM_VERSION }} diff --git a/.github/workflows/gha-publish-chart.yaml b/.github/workflows/gha-publish-chart.yaml index 8bcb407670..af42962ba1 100644 --- a/.github/workflows/gha-publish-chart.yaml +++ b/.github/workflows/gha-publish-chart.yaml @@ -82,10 +82,10 @@ jobs: echo "repository_owner=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - name: Set up QEMU - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 with: # Pinning v0.9.1 for Buildx and BuildKit v0.10.6 # BuildKit v0.11 which has a bug causing intermittent @@ -94,14 +94,14 @@ jobs: driver-opts: image=moby/buildkit:v0.10.6 - name: Login to GitHub Container Registry - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build & push controller image - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf with: context: . file: Dockerfile @@ -149,7 +149,7 @@ jobs: echo "repository_owner=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - name: Set up Helm - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 + uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 with: version: ${{ env.HELM_VERSION }} @@ -197,7 +197,7 @@ jobs: echo "repository_owner=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - name: Set up Helm - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 + uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 with: version: ${{ env.HELM_VERSION }} @@ -244,7 +244,7 @@ jobs: echo "repository_owner=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - name: Set up Helm - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 + uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 with: version: ${{ env.HELM_VERSION }} @@ -293,7 +293,7 @@ jobs: echo "repository_owner=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - name: Set up Helm - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 + uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 with: version: ${{ env.HELM_VERSION }} diff --git a/.github/workflows/gha-validate-chart.yaml b/.github/workflows/gha-validate-chart.yaml index 8e6ed541c4..949432ce96 100644 --- a/.github/workflows/gha-validate-chart.yaml +++ b/.github/workflows/gha-validate-chart.yaml @@ -41,7 +41,7 @@ jobs: fetch-depth: 0 - name: Set up Helm - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 + uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 with: version: ${{ env.HELM_VERSION }} @@ -79,7 +79,7 @@ jobs: fetch-depth: 0 - name: Set up Helm - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 + uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 with: version: ${{ env.HELM_VERSION }} diff --git a/.github/workflows/global-publish-canary.yaml b/.github/workflows/global-publish-canary.yaml index 0a1775650a..7adce96677 100644 --- a/.github/workflows/global-publish-canary.yaml +++ b/.github/workflows/global-publish-canary.yaml @@ -93,7 +93,7 @@ jobs: uses: actions/checkout@v7 - name: Login to GitHub Container Registry - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee with: registry: ghcr.io username: ${{ github.actor }} @@ -110,16 +110,16 @@ jobs: echo "repository_owner=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - name: Set up QEMU - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 with: version: latest # Unstable builds - run at your own risk - name: Build and Push - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf with: context: . file: ./Dockerfile diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index 402b6c2a82..6a30459fd6 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -48,7 +48,7 @@ jobs: go-version-file: "go.mod" cache: false - name: golangci-lint - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a + uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee with: only-new-issues: true version: v2.11.2 diff --git a/controllers/actions.github.com/autoscalinglistener_controller.go b/controllers/actions.github.com/autoscalinglistener_controller.go index d473b843bf..266d9d60f3 100644 --- a/controllers/actions.github.com/autoscalinglistener_controller.go +++ b/controllers/actions.github.com/autoscalinglistener_controller.go @@ -209,7 +209,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. desiredRole := r.newScaleSetListenerRole(&autoscalingListener) desiredLabels := r.filterAndMergeLabels(listenerRole.Labels, desiredRole.Labels) labelsModified := !maps.Equal(listenerRole.Labels, desiredLabels) - desiredAnnotations := desiredRole.Annotations + desiredAnnotations := r.mergeAnnotations(listenerRole.Annotations, desiredRole.Annotations) annotationsModified := !maps.Equal(listenerRole.Annotations, desiredAnnotations) rulesModified := !reflect.DeepEqual(listenerRole.Rules, desiredRole.Rules) if labelsModified || annotationsModified || rulesModified { @@ -251,7 +251,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. ) desiredLabels := r.filterAndMergeLabels(listenerRoleBinding.Labels, desiredRoleBinding.Labels) labelsModified := !maps.Equal(listenerRoleBinding.Labels, desiredLabels) - desiredAnnotations := desiredRoleBinding.Annotations + desiredAnnotations := r.mergeAnnotations(listenerRoleBinding.Annotations, desiredRoleBinding.Annotations) annotationsModified := !maps.Equal(listenerRoleBinding.Annotations, desiredAnnotations) if labelsModified || annotationsModified { updatedRoleBinding := listenerRoleBinding.DeepCopy() @@ -306,7 +306,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. } desiredLabels := r.filterAndMergeLabels(proxySecret.Labels, desiredListenerProxy.Labels) labelsModified := !maps.Equal(proxySecret.Labels, desiredLabels) - desiredAnnotations := desiredListenerProxy.Annotations + desiredAnnotations := r.mergeAnnotations(proxySecret.Annotations, desiredListenerProxy.Annotations) annotationsModified := !maps.Equal(proxySecret.Annotations, desiredAnnotations) if labelsModified || annotationsModified { updatedProxySecret := proxySecret.DeepCopy() @@ -392,7 +392,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. } desiredLabels := r.filterAndMergeLabels(listenerConfigSecret.Labels, desiredSecret.Labels) labelsModified := !maps.Equal(listenerConfigSecret.Labels, desiredLabels) - desiredAnnotations := desiredSecret.Annotations + desiredAnnotations := r.mergeAnnotations(listenerConfigSecret.Annotations, desiredSecret.Annotations) annotationsModified := !maps.Equal(listenerConfigSecret.Annotations, desiredAnnotations) if labelsModified || annotationsModified { @@ -463,11 +463,6 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, err } - desiredLabels := r.filterAndMergeLabels(listenerPod.Labels, desiredPod.Labels) - labelsModified := !maps.Equal(listenerPod.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(listenerPod.Annotations, desiredPod.Annotations) - annotationsModified := !maps.Equal(listenerPod.Annotations, desiredAnnotations) - shouldReCreate := listenerPodSpecRequiresRecreation(&listenerPod, desiredPod) if shouldReCreate { log.Info("Listener pod dependency changed, recreating listener pod") @@ -479,6 +474,11 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, nil } + desiredLabels := r.filterAndMergeLabels(listenerPod.Labels, desiredPod.Labels) + labelsModified := !maps.Equal(listenerPod.Labels, desiredLabels) + desiredAnnotations := r.mergeAnnotations(listenerPod.Annotations, desiredPod.Annotations) + annotationsModified := !maps.Equal(listenerPod.Annotations, desiredAnnotations) + if labelsModified || annotationsModified { updatedPod := listenerPod.DeepCopy() if labelsModified { diff --git a/controllers/actions.github.com/autoscalinglistener_controller_test.go b/controllers/actions.github.com/autoscalinglistener_controller_test.go index 9d32870323..67f7ecf5e7 100644 --- a/controllers/actions.github.com/autoscalinglistener_controller_test.go +++ b/controllers/actions.github.com/autoscalinglistener_controller_test.go @@ -401,8 +401,6 @@ var _ = Describe("Test AutoScalingListener controller", func() { autoscalingListenerTestInterval, ).Should(BeEquivalentTo(autoscalingListener.Name), "Pod should be created") - oldPodUID := string(pod.UID) - // Update the AutoScalingListener updated := autoscalingListener.DeepCopy() updated.Spec.EphemeralRunnerSetName = "test-ers-updated" @@ -423,20 +421,6 @@ var _ = Describe("Test AutoScalingListener controller", func() { autoscalingListenerTestTimeout, autoscalingListenerTestInterval, ).Should(BeEquivalentTo(rulesForListenerRole([]string{updated.Spec.EphemeralRunnerSetName})), "Role should be updated") - - Eventually( - func() (string, error) { - pod := new(corev1.Pod) - err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, pod) - if err != nil { - return "", err - } - - return string(pod.UID), nil - }, - autoscalingListenerTestTimeout, - autoscalingListenerTestInterval, - ).Should(BeEquivalentTo(oldPodUID), "Pod should not be re-created when only listener role rules change") }) It("propagates updated listener metadata to owned resources", func() { @@ -447,25 +431,37 @@ var _ = Describe("Test AutoScalingListener controller", func() { err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, serviceAccount) g.Expect(err).NotTo(HaveOccurred(), "failed to get ServiceAccount") g.Expect(serviceAccount.Labels["arc.test/listener-label"]).To(Equal(expected)) - g.Expect(serviceAccount.Annotations["arc.test/service-account-annotation"]).To(Equal("initial")) + g.Expect(serviceAccount.Annotations["arc.test/service-account-annotation"]).To(Equal(expected)) + if expected == "updated" { + g.Expect(serviceAccount.Annotations["arc.test/new-service-account-annotation"]).To(Equal("added")) + } role := new(rbacv1.Role) err = k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Spec.AutoscalingRunnerSetNamespace}, role) g.Expect(err).NotTo(HaveOccurred(), "failed to get Role") g.Expect(role.Labels["arc.test/listener-label"]).To(Equal(expected)) - g.Expect(role.Annotations["arc.test/role-annotation"]).To(Equal("initial")) + g.Expect(role.Annotations["arc.test/role-annotation"]).To(Equal(expected)) + if expected == "updated" { + g.Expect(role.Annotations["arc.test/new-role-annotation"]).To(Equal("added")) + } roleBinding := new(rbacv1.RoleBinding) err = k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Spec.AutoscalingRunnerSetNamespace}, roleBinding) g.Expect(err).NotTo(HaveOccurred(), "failed to get RoleBinding") g.Expect(roleBinding.Labels["arc.test/listener-label"]).To(Equal(expected)) - g.Expect(roleBinding.Annotations["arc.test/role-binding-annotation"]).To(Equal("initial")) + g.Expect(roleBinding.Annotations["arc.test/role-binding-annotation"]).To(Equal(expected)) + if expected == "updated" { + g.Expect(roleBinding.Annotations["arc.test/new-role-binding-annotation"]).To(Equal("added")) + } secret := new(corev1.Secret) err = k8sClient.Get(ctx, client.ObjectKey{Name: scaleSetListenerConfigName(autoscalingListener), Namespace: autoscalingListener.Namespace}, secret) g.Expect(err).NotTo(HaveOccurred(), "failed to get config Secret") - g.Expect(secret.Labels["arc.test/config-secret-label"]).To(Equal("initial")) - g.Expect(secret.Annotations["arc.test/config-secret-annotation"]).To(Equal("initial")) + g.Expect(secret.Labels["arc.test/config-secret-label"]).To(Equal(expected)) + g.Expect(secret.Annotations["arc.test/config-secret-annotation"]).To(Equal(expected)) + if expected == "updated" { + g.Expect(secret.Annotations["arc.test/new-config-secret-annotation"]).To(Equal("added")) + } pod := new(corev1.Pod) err = k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, pod) @@ -479,84 +475,45 @@ var _ = Describe("Test AutoScalingListener controller", func() { assertPropagatedMetadata("initial") - pod := new(corev1.Pod) - Eventually( - func() (string, error) { - err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, pod) - if err != nil { - return "", err - } - - return string(pod.UID), nil - }, - autoscalingListenerTestTimeout, - autoscalingListenerTestInterval, - ).ShouldNot(BeEmpty(), "Pod should be created") - - oldPodUID := string(pod.UID) - - serviceAccount := new(corev1.ServiceAccount) - err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, serviceAccount) - Expect(err).NotTo(HaveOccurred(), "failed to get ServiceAccount") - - updatedServiceAccount := serviceAccount.DeepCopy() - if updatedServiceAccount.Annotations == nil { - updatedServiceAccount.Annotations = make(map[string]string) - } - updatedServiceAccount.Annotations["arc.test/third-party-service-account-annotation"] = "preserved" - err = k8sClient.Patch(ctx, updatedServiceAccount, client.MergeFrom(serviceAccount)) - Expect(err).NotTo(HaveOccurred(), "failed to patch third-party ServiceAccount annotation") - - updatedPod := pod.DeepCopy() - if updatedPod.Annotations == nil { - updatedPod.Annotations = make(map[string]string) - } - updatedPod.Annotations["arc.test/third-party-pod-annotation"] = "preserved" - err = k8sClient.Patch(ctx, updatedPod, client.MergeFrom(pod)) - Expect(err).NotTo(HaveOccurred(), "failed to patch third-party Pod annotation") - current := new(v1alpha1.AutoscalingListener) - err = k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, current) + err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, current) Expect(err).NotTo(HaveOccurred(), "failed to get AutoScalingListener") updated := current.DeepCopy() updated.Labels = map[string]string{ "arc.test/listener-label": "updated", } + updated.Spec.ServiceAccountMetadata = &v1alpha1.ResourceMeta{ + Annotations: map[string]string{ + "arc.test/service-account-annotation": "updated", + "arc.test/new-service-account-annotation": "added", + }, + } + updated.Spec.RoleMetadata = &v1alpha1.ResourceMeta{ + Annotations: map[string]string{ + "arc.test/role-annotation": "updated", + "arc.test/new-role-annotation": "added", + }, + } + updated.Spec.RoleBindingMetadata = &v1alpha1.ResourceMeta{ + Annotations: map[string]string{ + "arc.test/role-binding-annotation": "updated", + "arc.test/new-role-binding-annotation": "added", + }, + } + updated.Spec.ConfigSecretMetadata = &v1alpha1.ResourceMeta{ + Labels: map[string]string{ + "arc.test/config-secret-label": "updated", + }, + Annotations: map[string]string{ + "arc.test/config-secret-annotation": "updated", + "arc.test/new-config-secret-annotation": "added", + }, + } err = k8sClient.Patch(ctx, updated, client.MergeFrom(current)) - Expect(err).NotTo(HaveOccurred(), "failed to patch AutoScalingListener labels") + Expect(err).NotTo(HaveOccurred(), "failed to patch AutoScalingListener metadata") assertPropagatedMetadata("updated") - - Eventually( - func(g Gomega) { - serviceAccount := new(corev1.ServiceAccount) - err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, serviceAccount) - g.Expect(err).NotTo(HaveOccurred(), "failed to get ServiceAccount") - g.Expect(serviceAccount.Annotations).To(HaveKeyWithValue("arc.test/third-party-service-account-annotation", "preserved")) - - pod := new(corev1.Pod) - err = k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, pod) - g.Expect(err).NotTo(HaveOccurred(), "failed to get Pod") - g.Expect(pod.Annotations).To(HaveKeyWithValue("arc.test/third-party-pod-annotation", "preserved")) - }, - autoscalingListenerTestTimeout, - autoscalingListenerTestInterval, - ).Should(Succeed()) - - Eventually( - func() (string, error) { - pod := new(corev1.Pod) - err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, pod) - if err != nil { - return "", err - } - - return string(pod.UID), nil - }, - autoscalingListenerTestTimeout, - autoscalingListenerTestInterval, - ).Should(BeEquivalentTo(oldPodUID), "Pod should be patched, not re-created, for metadata-only updates") }) It("It should re-create pod but persist config secret whenever listener container is terminated", func() { @@ -631,7 +588,6 @@ var _ = Describe("Test AutoScalingListener controller", func() { autoscalingListenerTestInterval, ).Should(BeEquivalentTo(oldSecretUID), "Config secret should persist (not be re-created)") }) - }) }) @@ -1141,64 +1097,6 @@ var _ = Describe("Test AutoScalingListener controller with proxy", func() { autoscalingListenerTestInterval, ).Should(Succeed(), "failed to delete secret with proxy details") }) - - It("should re-create listener pod when proxy dependency changes", func() { - proxy := &v1alpha1.ProxyConfig{ - HTTP: &v1alpha1.ProxyServerConfig{ - Url: "http://localhost:8080", - }, - NoProxy: []string{"example.com"}, - } - - createRunnerSetAndListener(proxy) - - pod := new(corev1.Pod) - Eventually( - func() (string, error) { - err := k8sClient.Get( - ctx, - client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, - pod, - ) - if err != nil { - return "", err - } - - return string(pod.UID), nil - }, - autoscalingListenerTestTimeout, - autoscalingListenerTestInterval, - ).ShouldNot(BeEmpty(), "Pod should be created") - - oldPodUID := string(pod.UID) - - current := new(v1alpha1.AutoscalingListener) - err := k8sClient.Get(ctx, client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, current) - Expect(err).NotTo(HaveOccurred(), "failed to get AutoScalingListener") - - updated := current.DeepCopy() - updated.Spec.Proxy.NoProxy = []string{"example.com", "example.org"} - err = k8sClient.Patch(ctx, updated, client.MergeFrom(current)) - Expect(err).NotTo(HaveOccurred(), "failed to patch AutoScalingListener proxy") - - Eventually( - func() (string, error) { - pod := new(corev1.Pod) - err := k8sClient.Get( - ctx, - client.ObjectKey{Name: autoscalingListener.Name, Namespace: autoscalingListener.Namespace}, - pod, - ) - if err != nil { - return "", err - } - - return string(pod.UID), nil - }, - autoscalingListenerTestTimeout, - autoscalingListenerTestInterval, - ).Should(BeEquivalentTo(oldPodUID), "Pod should not be re-created when proxy metadata updates do not change pod spec") - }) }) var _ = Describe("Test AutoScalingListener controller with template modification", func() { diff --git a/controllers/actions.github.com/ephemeralrunner_controller.go b/controllers/actions.github.com/ephemeralrunner_controller.go index 3371b5854b..256ed311c4 100644 --- a/controllers/actions.github.com/ephemeralrunner_controller.go +++ b/controllers/actions.github.com/ephemeralrunner_controller.go @@ -186,8 +186,8 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ log.Info("Successfully added finalizers") } - var secret corev1.Secret - if err := r.Get(ctx, req.NamespacedName, &secret); err != nil { + secret := new(corev1.Secret) + if err := r.Get(ctx, req.NamespacedName, secret); err != nil { if !kerrors.IsNotFound(err) { log.Error(err, "Failed to fetch secret") return ctrl.Result{}, err @@ -203,7 +203,7 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ return ctrl.Result{}, fmt.Errorf("failed to create secret: %w", err) } log.Info("Created new ephemeral runner secret for jitconfig.") - secret = *jitSecret + secret = jitSecret case errors.Is(err, retryableError): log.Info("Encountered retryable error, requeueing", "error", err.Error()) @@ -227,7 +227,7 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ if err != nil { log.Error(err, "Runner config secret is corrupted: missing runnerId") log.Info("Deleting corrupted runner config secret") - if err := r.Delete(ctx, &secret); err != nil { + if err := r.Delete(ctx, secret); err != nil { return ctrl.Result{}, fmt.Errorf("failed to delete the corrupted runner config secret") } log.Info("Corrupted runner config secret has been deleted") @@ -273,15 +273,15 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ }, nil } - var pod corev1.Pod - if err := r.Get(ctx, req.NamespacedName, &pod); err != nil { + pod := new(corev1.Pod) + if err := r.Get(ctx, req.NamespacedName, pod); err != nil { if !kerrors.IsNotFound(err) { log.Error(err, "Failed to fetch the pod") return ctrl.Result{}, err } log.Info("Ephemeral runner pod does not exist. Creating new ephemeral runner") - result, err := r.createPod(ctx, &ephemeralRunner, &secret, log) + result, err := r.createPod(ctx, &ephemeralRunner, secret, log) switch { case err == nil: return result, nil @@ -329,7 +329,7 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ } } - cs := runnerContainerStatus(&pod) + cs := runnerContainerStatus(pod) switch { case pod.Status.Phase == corev1.PodFailed: // All containers are stopped log.Info( @@ -342,7 +342,7 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ // Therefore, we should try to restart it. if cs == nil || cs.State.Terminated == nil { log.Info("Runner container does not have state set, deleting pod as failed so it can be restarted") - return ctrl.Result{}, r.deleteEphemeralRunnerOrPod(ctx, &ephemeralRunner, &pod, log) + return ctrl.Result{}, r.deleteEphemeralRunnerOrPod(ctx, &ephemeralRunner, pod, log) } switch cs.State.Terminated.ExitCode { @@ -352,7 +352,7 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ // If the runner container exits with 0, we assume that the runner has finished successfully. // If side-car container exits with non-zero, it shouldn't affect the runner. Runner exit code // drives the controller's inference of whether the job has succeeded or failed. - if err := r.markAsSucceeded(ctx, &ephemeralRunner, &pod, log); err != nil { + if err := r.markAsSucceeded(ctx, &ephemeralRunner, pod, log); err != nil { log.Error(err, "Failed to set ephemeral runner to phase Succeeded") return ctrl.Result{}, err } @@ -374,14 +374,14 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ "Ephemeral runner container has failed, and runner container termination exit code is non-zero", "containerTerminatedState", cs.State.Terminated, ) - return ctrl.Result{}, r.deleteEphemeralRunnerOrPod(ctx, &ephemeralRunner, &pod, log) + return ctrl.Result{}, r.deleteEphemeralRunnerOrPod(ctx, &ephemeralRunner, pod, log) - case initContainerFailed(&pod): + case initContainerFailed(pod): log.Info( "Pod has a failed init container, deleting pod as failed so it can be restarted", "initContainerStatuses", pod.Status.InitContainerStatuses, ) - return ctrl.Result{}, r.deleteEphemeralRunnerOrPod(ctx, &ephemeralRunner, &pod, log) + return ctrl.Result{}, r.deleteEphemeralRunnerOrPod(ctx, &ephemeralRunner, pod, log) case cs == nil: // starting, no container state yet @@ -390,7 +390,7 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ case cs.State.Terminated == nil: // container is not terminated and pod phase is not failed, so runner is still running log.Info("Runner container is still running; updating ephemeral runner status") - if err := r.updateRunStatusFromPod(ctx, &ephemeralRunner, &pod, log); err != nil { + if err := r.updateRunStatusFromPod(ctx, &ephemeralRunner, pod, log); err != nil { log.Info("Failed to update ephemeral runner status. Requeue to not miss this event") return ctrl.Result{}, err } @@ -405,11 +405,11 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ case cs.State.Terminated.ExitCode != 0: // failed log.Info("Ephemeral runner container failed", "exitCode", cs.State.Terminated.ExitCode) - return ctrl.Result{}, r.deleteEphemeralRunnerOrPod(ctx, &ephemeralRunner, &pod, log) + return ctrl.Result{}, r.deleteEphemeralRunnerOrPod(ctx, &ephemeralRunner, pod, log) default: // succeeded log.Info("Ephemeral runner has finished successfully, deleting ephemeral runner", "exitCode", cs.State.Terminated.ExitCode) - if err := r.markAsSucceeded(ctx, &ephemeralRunner, &pod, log); err != nil { + if err := r.markAsSucceeded(ctx, &ephemeralRunner, pod, log); err != nil { log.Error(err, "Failed to set ephemeral runner to phase Succeeded") return ctrl.Result{}, err } @@ -471,13 +471,13 @@ func (r *EphemeralRunnerReconciler) cleanupRunnerFromService(ctx context.Context func (r *EphemeralRunnerReconciler) cleanupResources(ctx context.Context, ephemeralRunner *v1alpha1.EphemeralRunner, log logr.Logger) error { log.Info("Cleaning up the runner pod") - var pod corev1.Pod - err := r.Get(ctx, types.NamespacedName{Namespace: ephemeralRunner.Namespace, Name: ephemeralRunner.Name}, &pod) + pod := new(corev1.Pod) + err := r.Get(ctx, types.NamespacedName{Namespace: ephemeralRunner.Namespace, Name: ephemeralRunner.Name}, pod) switch { case err == nil: if pod.DeletionTimestamp.IsZero() { log.Info("Deleting the runner pod") - if err := r.Delete(ctx, &pod); err != nil && !kerrors.IsNotFound(err) { + if err := r.Delete(ctx, pod); err != nil && !kerrors.IsNotFound(err) { return fmt.Errorf("failed to delete pod: %w", err) } log.Info("Deleted the runner pod") @@ -491,13 +491,13 @@ func (r *EphemeralRunnerReconciler) cleanupResources(ctx context.Context, epheme } log.Info("Cleaning up the runner jitconfig secret") - var secret corev1.Secret - err = r.Get(ctx, types.NamespacedName{Namespace: ephemeralRunner.Namespace, Name: ephemeralRunner.Name}, &secret) + secret := new(corev1.Secret) + err = r.Get(ctx, types.NamespacedName{Namespace: ephemeralRunner.Namespace, Name: ephemeralRunner.Name}, secret) switch { case err == nil: if secret.DeletionTimestamp.IsZero() { log.Info("Deleting the jitconfig secret") - if err := r.Delete(ctx, &secret); err != nil && !kerrors.IsNotFound(err) { + if err := r.Delete(ctx, secret); err != nil && !kerrors.IsNotFound(err) { return fmt.Errorf("failed to delete secret: %w", err) } log.Info("Deleted jitconfig secret") diff --git a/controllers/actions.github.com/helpers.go b/controllers/actions.github.com/helpers.go index 8d90f5ae65..72188ae631 100644 --- a/controllers/actions.github.com/helpers.go +++ b/controllers/actions.github.com/helpers.go @@ -5,13 +5,11 @@ import ( "github.com/google/go-cmp/cmp" corev1 "k8s.io/api/core/v1" apiequality "k8s.io/apimachinery/pkg/api/equality" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) var ( _ = ephemeralRunnerSetActionableSpecChanged _ = nextActionableRevision - _ = listenerPodCanonicalEqual ) func ephemeralRunnerSetActionableSpecChanged(current, desired *v1alpha1.EphemeralRunnerSet) bool { @@ -34,28 +32,6 @@ func nextActionableRevision(current *v1alpha1.EphemeralRunnerSet) int64 { return current.Status.AppliedActionableRevision + 1 } -func listenerPodCanonicalForComparison(pod *corev1.Pod) *corev1.Pod { - if pod == nil { - return nil - } - - canonical := pod.DeepCopy() - canonical.UID = "" - canonical.ResourceVersion = "" - canonical.ManagedFields = nil - canonical.CreationTimestamp = metav1.Time{} - canonical.DeletionTimestamp = nil - canonical.Finalizers = nil - canonical.Generation = 0 - canonical.Status = corev1.PodStatus{} - - return canonical -} - -func listenerPodCanonicalEqual(current, desired *corev1.Pod) bool { - return cmp.Equal(listenerPodCanonicalForComparison(current), listenerPodCanonicalForComparison(desired)) -} - func listenerPodSpecRequiresRecreation(current, desired *corev1.Pod) bool { if current == nil || desired == nil { return current != desired diff --git a/controllers/actions.github.com/helpers_test.go b/controllers/actions.github.com/helpers_test.go index f426db37f5..b798958623 100644 --- a/controllers/actions.github.com/helpers_test.go +++ b/controllers/actions.github.com/helpers_test.go @@ -2,11 +2,8 @@ package actionsgithubcom import ( "context" - "testing" - "github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1" "github.com/onsi/ginkgo/v2" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/sync/errgroup" corev1 "k8s.io/api/core/v1" @@ -88,197 +85,3 @@ func createDefaultSecret(t ginkgo.GinkgoTInterface, client client.Client, namesp return secret } - -func TestEphemeralRunnerSetActionableSpecChanged(t *testing.T) { - base := func() *v1alpha1.EphemeralRunnerSet { - return &v1alpha1.EphemeralRunnerSet{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"app": "arc"}, - Annotations: map[string]string{"note": "keep"}, - }, - Spec: v1alpha1.EphemeralRunnerSetSpec{ - Replicas: 1, - PatchID: 10, - EphemeralRunnerSpec: v1alpha1.EphemeralRunnerSpec{ - PodTemplateSpec: corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{{Name: "runner", Image: "ghcr.io/actions/runner:old"}}, - }, - }, - }, - EphemeralRunnerMetadata: &v1alpha1.ResourceMeta{ - Labels: map[string]string{"meta-label": "v1"}, - Annotations: map[string]string{"meta-annotation": "v1"}, - }, - }, - } - } - - tests := []struct { - name string - mutate func(current, desired *v1alpha1.EphemeralRunnerSet) - want bool - }{ - { - name: "ephemeral runner image change is actionable", - mutate: func(_ *v1alpha1.EphemeralRunnerSet, desired *v1alpha1.EphemeralRunnerSet) { - desired.Spec.EphemeralRunnerSpec.PodTemplateSpec.Spec.Containers[0].Image = "ghcr.io/actions/runner:new" - }, - want: true, - }, - { - name: "ephemeral runner template change is actionable", - mutate: func(_ *v1alpha1.EphemeralRunnerSet, desired *v1alpha1.EphemeralRunnerSet) { - desired.Spec.EphemeralRunnerSpec.PodTemplateSpec.Spec.NodeSelector = map[string]string{"kubernetes.io/os": "linux"} - }, - want: true, - }, - { - name: "replicas change is non-actionable", - mutate: func(_ *v1alpha1.EphemeralRunnerSet, desired *v1alpha1.EphemeralRunnerSet) { - desired.Spec.Replicas = 3 - }, - want: false, - }, - { - name: "patch id change is non-actionable", - mutate: func(_ *v1alpha1.EphemeralRunnerSet, desired *v1alpha1.EphemeralRunnerSet) { - desired.Spec.PatchID = 11 - }, - want: false, - }, - { - name: "set labels change is non-actionable", - mutate: func(_ *v1alpha1.EphemeralRunnerSet, desired *v1alpha1.EphemeralRunnerSet) { - desired.Labels["app"] = "changed" - }, - want: false, - }, - { - name: "set annotations change is non-actionable", - mutate: func(_ *v1alpha1.EphemeralRunnerSet, desired *v1alpha1.EphemeralRunnerSet) { - desired.Annotations["note"] = "changed" - }, - want: false, - }, - { - name: "ephemeral runner metadata change is non-actionable", - mutate: func(_ *v1alpha1.EphemeralRunnerSet, desired *v1alpha1.EphemeralRunnerSet) { - desired.Spec.EphemeralRunnerMetadata.Annotations["meta-annotation"] = "v2" - }, - want: false, - }, - { - name: "nil metadata transition is non-actionable", - mutate: func(current, desired *v1alpha1.EphemeralRunnerSet) { - current.Spec.EphemeralRunnerMetadata = nil - desired.Spec.EphemeralRunnerMetadata = &v1alpha1.ResourceMeta{Labels: map[string]string{"meta-label": "new"}} - }, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - current := base() - desired := current.DeepCopy() - tt.mutate(current, desired) - - assert.Equal(t, tt.want, ephemeralRunnerSetActionableSpecChanged(current, desired)) - }) - } -} - -func TestNextActionableRevision(t *testing.T) { - tests := []struct { - name string - current *v1alpha1.EphemeralRunnerSet - want int64 - }{ - {name: "nil current starts at one", current: nil, want: 1}, - { - name: "spec revision ahead", - current: &v1alpha1.EphemeralRunnerSet{Spec: v1alpha1.EphemeralRunnerSetSpec{ActionableRevision: 3}, Status: v1alpha1.EphemeralRunnerSetStatus{AppliedActionableRevision: 2}}, - want: 4, - }, - { - name: "applied revision ahead", - current: &v1alpha1.EphemeralRunnerSet{Spec: v1alpha1.EphemeralRunnerSetSpec{ActionableRevision: 2}, Status: v1alpha1.EphemeralRunnerSetStatus{AppliedActionableRevision: 7}}, - want: 8, - }, - { - name: "equal revisions", - current: &v1alpha1.EphemeralRunnerSet{Spec: v1alpha1.EphemeralRunnerSetSpec{ActionableRevision: 5}, Status: v1alpha1.EphemeralRunnerSetStatus{AppliedActionableRevision: 5}}, - want: 6, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, nextActionableRevision(tt.current)) - }) - } -} - -func TestListenerPodCanonicalEqual(t *testing.T) { - base := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "listener", - Namespace: "controller-ns", - UID: "uid-1", - ResourceVersion: "100", - Annotations: map[string]string{"keep": "v"}, - Labels: map[string]string{"app": "listener"}, - ManagedFields: []metav1.ManagedFieldsEntry{{Manager: "kube-controller-manager"}}, - }, - Spec: corev1.PodSpec{ - ServiceAccountName: "listener-sa", - Containers: []corev1.Container{{Name: "listener", Image: "ghcr.io/actions/listener:v1"}}, - }, - Status: corev1.PodStatus{Phase: corev1.PodRunning}, - } - - tests := []struct { - name string - mutate func(current, desired *corev1.Pod) - want bool - }{ - { - name: "ignores runtime fields", - mutate: func(current, desired *corev1.Pod) { - current.UID = "uid-current" - desired.UID = "uid-desired" - current.ResourceVersion = "101" - desired.ResourceVersion = "202" - current.ManagedFields = []metav1.ManagedFieldsEntry{{Manager: "a"}} - desired.ManagedFields = []metav1.ManagedFieldsEntry{{Manager: "b"}} - current.Status.Phase = corev1.PodPending - desired.Status.Phase = corev1.PodFailed - }, - want: true, - }, - { - name: "spec change is not equal", - mutate: func(_ *corev1.Pod, desired *corev1.Pod) { - desired.Spec.Containers[0].Image = "ghcr.io/actions/listener:v2" - }, - want: false, - }, - { - name: "non-legacy annotation change is not equal", - mutate: func(_ *corev1.Pod, desired *corev1.Pod) { - desired.Annotations["keep"] = "different" - }, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - current := base.DeepCopy() - desired := base.DeepCopy() - tt.mutate(current, desired) - assert.Equal(t, tt.want, listenerPodCanonicalEqual(current, desired)) - }) - } -} diff --git a/controllers/actions.github.com/resourcebuilder.go b/controllers/actions.github.com/resourcebuilder.go index cda96f18f1..c660a94f4f 100644 --- a/controllers/actions.github.com/resourcebuilder.go +++ b/controllers/actions.github.com/resourcebuilder.go @@ -128,8 +128,7 @@ func (b *ResourceBuilder) newAutoscalingListener(autoscalingRunnerSet *v1alpha1. Image: image, ImagePullSecrets: imagePullSecrets, }) - metadataDependency := resourceCacheObjectMetadataInputObject(autoscalingRunnerSet) - if cached, ok := b.ResourceCache.autoscalingListener.Get(autoscalingRunnerSet, cacheKeyObject, ephemeralRunnerSet, inputDependency, metadataDependency); ok { + if cached, ok := b.ResourceCache.autoscalingListener.Get(autoscalingRunnerSet, cacheKeyObject, ephemeralRunnerSet, inputDependency); ok { return cached, nil } @@ -182,6 +181,7 @@ func (b *ResourceBuilder) newAutoscalingListener(autoscalingRunnerSet *v1alpha1. labels = b.filterAndMergeLabels(autoscalingRunnerSet.Spec.AutoscalingListenerMetadata.Labels, labels) annotations = b.mergeAnnotations(autoscalingRunnerSet.Spec.AutoscalingListenerMetadata.Annotations, annotations) } + autoscalingListener := &v1alpha1.AutoscalingListener{ TypeMeta: metav1.TypeMeta{ APIVersion: v1alpha1.GroupVersion.String(), @@ -195,7 +195,7 @@ func (b *ResourceBuilder) newAutoscalingListener(autoscalingRunnerSet *v1alpha1. }, Spec: spec, } - b.ResourceCache.autoscalingListener.Upsert(autoscalingRunnerSet, autoscalingListener, ephemeralRunnerSet, inputDependency, metadataDependency) + b.ResourceCache.autoscalingListener.Upsert(autoscalingRunnerSet, autoscalingListener, ephemeralRunnerSet, inputDependency) return autoscalingListener, nil } @@ -213,20 +213,6 @@ func resourceCacheInputObject(name string, value any) client.Object { } } -func resourceCacheObjectMetadataInputObject(object client.Object) client.Object { - return resourceCacheInputObject(resourceCacheObjectName(object)+"-metadata", struct { - Namespace string - Name string - Labels map[string]string - Annotations map[string]string - }{ - Namespace: object.GetNamespace(), - Name: object.GetName(), - Labels: object.GetLabels(), - Annotations: object.GetAnnotations(), - }) -} - type listenerMetricsServerConfig struct { addr string endpoint string @@ -306,6 +292,7 @@ func (b *ResourceBuilder) newScaleSetListenerConfig(autoscalingListener *v1alpha if autoscalingListener.Spec.ConfigSecretMetadata != nil && len(autoscalingListener.Spec.ConfigSecretMetadata.Annotations) > 0 { annotations = autoscalingListener.Spec.ConfigSecretMetadata.Annotations } + desiredSecret := &corev1.Secret{ TypeMeta: metav1.TypeMeta{ APIVersion: corev1.SchemeGroupVersion.String(), @@ -343,8 +330,7 @@ func (b *ResourceBuilder) newScaleSetListenerPod( Namespace: autoscalingListener.Namespace, }, } - metadataDependency := resourceCacheObjectMetadataInputObject(autoscalingListener) - if cached, ok := b.ResourceCache.listenerPod.Get(autoscalingListener, cacheKeyObject, podConfig, serviceAccount, role, roleBinding, metadataDependency); ok { + if cached, ok := b.ResourceCache.listenerPod.Get(autoscalingListener, cacheKeyObject, podConfig, serviceAccount, role, roleBinding); ok { return cached, nil } @@ -474,7 +460,7 @@ func (b *ResourceBuilder) newScaleSetListenerPod( if autoscalingListener.Spec.Template != nil { mergeListenerPodWithTemplate(newRunnerScaleSetListenerPod, autoscalingListener.Spec.Template) } - b.ResourceCache.listenerPod.Upsert(autoscalingListener, newRunnerScaleSetListenerPod, podConfig, serviceAccount, role, roleBinding, metadataDependency) + b.ResourceCache.listenerPod.Upsert(autoscalingListener, newRunnerScaleSetListenerPod, podConfig, serviceAccount, role, roleBinding) return newRunnerScaleSetListenerPod, nil } @@ -603,8 +589,7 @@ func (b *ResourceBuilder) newScaleSetListenerServiceAccount(autoscalingListener Namespace: autoscalingListener.Namespace, }, } - metadataDependency := resourceCacheObjectMetadataInputObject(autoscalingListener) - if cached, ok := b.ResourceCache.listenerServiceAccount.Get(autoscalingListener, cacheKeyObject, metadataDependency); ok { + if cached, ok := b.ResourceCache.listenerServiceAccount.Get(autoscalingListener, cacheKeyObject); ok { return cached, nil } @@ -628,10 +613,11 @@ func (b *ResourceBuilder) newScaleSetListenerServiceAccount(autoscalingListener base.Labels = b.filterAndMergeLabels(autoscalingListener.Spec.ServiceAccountMetadata.Labels, base.Labels) base.Annotations = b.mergeAnnotations(autoscalingListener.Spec.ServiceAccountMetadata.Annotations, base.Annotations) } + if err := b.setControllerReference(autoscalingListener, base); err != nil { return nil, fmt.Errorf("failed to set controller reference for listener service account: %w", err) } - b.ResourceCache.listenerServiceAccount.Upsert(autoscalingListener, base, metadataDependency) + b.ResourceCache.listenerServiceAccount.Upsert(autoscalingListener, base) return base, nil } @@ -643,8 +629,7 @@ func (b *ResourceBuilder) newScaleSetListenerRole(autoscalingListener *v1alpha1. Namespace: autoscalingListener.Spec.AutoscalingRunnerSetNamespace, }, } - metadataDependency := resourceCacheObjectMetadataInputObject(autoscalingListener) - if cached, ok := b.ResourceCache.listenerRole.Get(autoscalingListener, cacheKeyObject, metadataDependency); ok { + if cached, ok := b.ResourceCache.listenerRole.Get(autoscalingListener, cacheKeyObject); ok { return cached } @@ -660,6 +645,7 @@ func (b *ResourceBuilder) newScaleSetListenerRole(autoscalingListener *v1alpha1. labels = b.filterAndMergeLabels(autoscalingListener.Spec.RoleMetadata.Labels, labels) annotations = b.mergeAnnotations(autoscalingListener.Spec.RoleMetadata.Annotations, nil) } + newRole := &rbacv1.Role{ TypeMeta: metav1.TypeMeta{ APIVersion: rbacv1.SchemeGroupVersion.String(), @@ -674,7 +660,7 @@ func (b *ResourceBuilder) newScaleSetListenerRole(autoscalingListener *v1alpha1. Rules: rulesForListenerRole([]string{autoscalingListener.Spec.EphemeralRunnerSetName}), } - b.ResourceCache.listenerRole.Upsert(autoscalingListener, newRole, metadataDependency) + b.ResourceCache.listenerRole.Upsert(autoscalingListener, newRole) return newRole } @@ -686,8 +672,7 @@ func (b *ResourceBuilder) newScaleSetListenerRoleBinding(autoscalingListener *v1 Namespace: autoscalingListener.Spec.AutoscalingRunnerSetNamespace, }, } - metadataDependency := resourceCacheObjectMetadataInputObject(autoscalingListener) - if cached, ok := b.ResourceCache.listenerRoleBinding.Get(autoscalingListener, cacheKeyObject, listenerRole, serviceAccount, metadataDependency); ok { + if cached, ok := b.ResourceCache.listenerRoleBinding.Get(autoscalingListener, cacheKeyObject, listenerRole, serviceAccount); ok { return cached } @@ -716,6 +701,7 @@ func (b *ResourceBuilder) newScaleSetListenerRoleBinding(autoscalingListener *v1 labels = b.filterAndMergeLabels(autoscalingListener.Spec.RoleBindingMetadata.Labels, labels) annotations = autoscalingListener.Spec.RoleBindingMetadata.Annotations } + newRoleBinding := &rbacv1.RoleBinding{ TypeMeta: metav1.TypeMeta{ APIVersion: rbacv1.SchemeGroupVersion.String(), @@ -731,7 +717,7 @@ func (b *ResourceBuilder) newScaleSetListenerRoleBinding(autoscalingListener *v1 Subjects: subjects, } - b.ResourceCache.listenerRoleBinding.Upsert(autoscalingListener, newRoleBinding, listenerRole, serviceAccount, metadataDependency) + b.ResourceCache.listenerRoleBinding.Upsert(autoscalingListener, newRoleBinding, listenerRole, serviceAccount) return newRoleBinding } @@ -748,8 +734,7 @@ func (b *ResourceBuilder) newEphemeralRunnerSet(autoscalingRunnerSet *v1alpha1.A Namespace: autoscalingRunnerSet.Namespace, }, } - metadataDependency := resourceCacheObjectMetadataInputObject(autoscalingRunnerSet) - if cached, ok := b.ResourceCache.ephemeralRunnerSet.Get(autoscalingRunnerSet, cacheKeyObject, metadataDependency); ok { + if cached, ok := b.ResourceCache.ephemeralRunnerSet.Get(autoscalingRunnerSet, cacheKeyObject); ok { return cached, nil } @@ -789,6 +774,7 @@ func (b *ResourceBuilder) newEphemeralRunnerSet(autoscalingRunnerSet *v1alpha1.A labels = b.filterAndMergeLabels(autoscalingRunnerSet.Spec.EphemeralRunnerSetMetadata.Labels, labels) annotations = b.mergeAnnotations(autoscalingRunnerSet.Spec.EphemeralRunnerSetMetadata.Annotations, annotations) } + newEphemeralRunnerSet := &v1alpha1.EphemeralRunnerSet{ TypeMeta: metav1.TypeMeta{ APIVersion: v1alpha1.GroupVersion.String(), @@ -806,7 +792,7 @@ func (b *ResourceBuilder) newEphemeralRunnerSet(autoscalingRunnerSet *v1alpha1.A if err := b.setControllerReference(autoscalingRunnerSet, newEphemeralRunnerSet); err != nil { return nil, fmt.Errorf("failed to set controller reference for ephemeral runner set: %w", err) } - b.ResourceCache.ephemeralRunnerSet.Upsert(autoscalingRunnerSet, newEphemeralRunnerSet, metadataDependency) + b.ResourceCache.ephemeralRunnerSet.Upsert(autoscalingRunnerSet, newEphemeralRunnerSet) return newEphemeralRunnerSet, nil } @@ -845,6 +831,7 @@ func (b *ResourceBuilder) newEphemeralRunner(ephemeralRunnerSet *v1alpha1.Epheme labels = b.filterAndMergeLabels(ephemeralRunnerSet.Spec.EphemeralRunnerMetadata.Labels, labels) annotations = b.mergeAnnotations(ephemeralRunnerSet.Spec.EphemeralRunnerMetadata.Annotations, annotations) } + ephemeralRunner := &v1alpha1.EphemeralRunner{ ObjectMeta: metav1.ObjectMeta{ GenerateName: ephemeralRunnerSet.Name + "-runner-", @@ -871,6 +858,7 @@ func (b *ResourceBuilder) newEphemeralRunnerPod(runner *v1alpha1.EphemeralRunner annotations := make(map[string]string, len(runner.Annotations)+len(runner.Spec.Annotations)) maps.Copy(annotations, runner.Annotations) maps.Copy(annotations, runner.Spec.Annotations) + labels := make(map[string]string, len(runner.Labels)+len(runner.Spec.Labels)+2) maps.Copy(labels, runner.Labels) maps.Copy(labels, runner.Spec.Labels) @@ -940,6 +928,7 @@ func (b *ResourceBuilder) newEphemeralRunnerJitSecret(ephemeralRunner *v1alpha1. labels = b.filterAndMergeLabels(ephemeralRunner.Spec.EphemeralRunnerConfigSecretMetadata.Labels, nil) annotations = ephemeralRunner.Spec.EphemeralRunnerConfigSecretMetadata.Annotations } + jitSecret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: ephemeralRunner.Name, diff --git a/controllers/actions.github.com/resourcebuilder_test.go b/controllers/actions.github.com/resourcebuilder_test.go index e71d079ae8..9e91790d6c 100644 --- a/controllers/actions.github.com/resourcebuilder_test.go +++ b/controllers/actions.github.com/resourcebuilder_test.go @@ -115,6 +115,7 @@ func TestMetadataPropagation(t *testing.T) { assert.Equal(t, labelValueKubernetesPartOf, ephemeralRunnerSet.Labels[LabelKeyKubernetesPartOf]) assert.Equal(t, "runner-set", ephemeralRunnerSet.Labels[LabelKeyKubernetesComponent]) assert.Equal(t, autoscalingRunnerSet.Labels[LabelKeyKubernetesVersion], ephemeralRunnerSet.Labels[LabelKeyKubernetesVersion]) + assert.NotContains(t, ephemeralRunnerSet.Annotations, "actions.github.com/integrity-hash") assert.Equal(t, autoscalingRunnerSet.Name, ephemeralRunnerSet.Labels[LabelKeyGitHubScaleSetName]) assert.Equal(t, autoscalingRunnerSet.Namespace, ephemeralRunnerSet.Labels[LabelKeyGitHubScaleSetNamespace]) assert.Equal(t, "", ephemeralRunnerSet.Labels[LabelKeyGitHubEnterprise]) @@ -131,6 +132,7 @@ func TestMetadataPropagation(t *testing.T) { assert.Equal(t, labelValueKubernetesPartOf, listener.Labels[LabelKeyKubernetesPartOf]) assert.Equal(t, "runner-scale-set-listener", listener.Labels[LabelKeyKubernetesComponent]) assert.Equal(t, autoscalingRunnerSet.Labels[LabelKeyKubernetesVersion], listener.Labels[LabelKeyKubernetesVersion]) + assert.NotContains(t, listener.Annotations, "actions.github.com/integrity-hash") assert.Equal(t, autoscalingRunnerSet.Name, listener.Labels[LabelKeyGitHubScaleSetName]) assert.Equal(t, autoscalingRunnerSet.Namespace, listener.Labels[LabelKeyGitHubScaleSetNamespace]) assert.Equal(t, "", listener.Labels[LabelKeyGitHubEnterprise]) @@ -204,6 +206,31 @@ func TestMetadataPropagation(t *testing.T) { } } +func TestEphemeralRunnerSetProxySecretMetadata(t *testing.T) { + ephemeralRunnerSet := &v1alpha1.EphemeralRunnerSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-scale-set", + Namespace: "test-ns", + Labels: map[string]string{ + LabelKeyGitHubScaleSetName: "test-scale-set", + LabelKeyGitHubScaleSetNamespace: "test-ns", + }, + }, + } + + var b ResourceBuilder + proxySecret, err := b.newEphemeralRunnerSetProxySecret(ephemeralRunnerSet, map[string][]byte{ + "http_proxy": []byte("http://proxy.example.com"), + }) + require.NoError(t, err) + + assert.Equal(t, proxyEphemeralRunnerSetSecretName(ephemeralRunnerSet), proxySecret.Name) + assert.Equal(t, ephemeralRunnerSet.Namespace, proxySecret.Namespace) + assert.Equal(t, ephemeralRunnerSet.Labels[LabelKeyGitHubScaleSetName], proxySecret.Labels[LabelKeyGitHubScaleSetName]) + assert.Equal(t, ephemeralRunnerSet.Labels[LabelKeyGitHubScaleSetNamespace], proxySecret.Labels[LabelKeyGitHubScaleSetNamespace]) + assert.NotContains(t, proxySecret.Annotations, "actions.github.com/integrity-hash") +} + func TestGitHubURLTrimLabelValues(t *testing.T) { enterprise := strings.Repeat("a", 64) organization := strings.Repeat("b", 64) diff --git a/controllers/actions.github.com/resourcecache.go b/controllers/actions.github.com/resourcecache.go index ec99c6bce5..ccbec79f6b 100644 --- a/controllers/actions.github.com/resourcecache.go +++ b/controllers/actions.github.com/resourcecache.go @@ -19,7 +19,7 @@ const ( resourceCacheInitialEntries = 4096 resourceCacheInitialMainUIDEntries = 4096 resourceCacheInitialOwnerEntries = 8 - resourceCacheMaxDependencyRefs = 5 + resourceCacheMaxDependencyRefs = 4 ) type ResourceCacheObjectRef struct { @@ -28,7 +28,6 @@ type ResourceCacheObjectRef struct { Name string UID types.UID ResourceVersion string - Generation int64 // Used for CR owner identity (main objects), zero for dependencies/desired objects } type ResourceCacheKey struct { @@ -100,15 +99,11 @@ func (s *resourceCacheState[T]) Get( } key := newResourceCacheKey(mainObject, desiredObject) - mainObjectRef := newResourceCacheMainObjectRef(mainObject) - resourceVersion := desiredObject.GetResourceVersion() - if resourceVersion == "" && !isResourceCacheLookupObject(desiredObject) { - resourceVersion = hash.ComputeTemplateHash(desiredObject) - } + mainObjectRef := newResourceCacheObjectRef(mainObject) s.mu.RLock() value, ok := s.entries[key] - if ok && value.MainObject == mainObjectRef && (resourceVersion == "" || value.ResourceVersion == resourceVersion) && value.dependencyKey.Equal(dependencyKey) { + if ok && value.MainObject == mainObjectRef && value.dependencyKey.Equal(dependencyKey) { s.mu.RUnlock() return value.Object, true } @@ -135,11 +130,8 @@ func (s *resourceCacheState[T]) Upsert( } key := newResourceCacheKey(mainObject, desiredObject) - mainObjectRef := newResourceCacheMainObjectRef(mainObject) + mainObjectRef := newResourceCacheObjectRef(mainObject) resourceVersion := desiredObject.GetResourceVersion() - if resourceVersion == "" { - resourceVersion = hash.ComputeTemplateHash(desiredObject) - } s.mu.RLock() previous, ok := s.entries[key] @@ -227,7 +219,7 @@ func newResourceCacheDependencyKey(objects ...client.Object) (resourceCacheDepen if isNilResourceCacheObject(object) { return resourceCacheDependencyKey{}, false } - key.refs[i] = newResourceCacheDependencyObjectRef(object) + key.refs[i] = newResourceCacheObjectRef(object) } slices.SortFunc(key.refs[:key.count], func(a, b ResourceCacheObjectRef) int { return compareResourceCacheObjectRefs(a, b) @@ -243,7 +235,7 @@ func (k resourceCacheDependencyKey) Equal(other resourceCacheDependencyKey) bool return false } - for i := range k.count { + for i := 0; i < k.count; i++ { if k.refs[i] != other.refs[i] { return false } @@ -252,17 +244,7 @@ func (k resourceCacheDependencyKey) Equal(other resourceCacheDependencyKey) bool return true } -func newResourceCacheMainObjectRef(object client.Object) ResourceCacheObjectRef { - return ResourceCacheObjectRef{ - ObjectType: object.GetObjectKind().GroupVersionKind(), - Namespace: object.GetNamespace(), - Name: resourceCacheObjectName(object), - UID: object.GetUID(), - Generation: object.GetGeneration(), - } -} - -func newResourceCacheDependencyObjectRef(object client.Object) ResourceCacheObjectRef { +func newResourceCacheObjectRef(object client.Object) ResourceCacheObjectRef { resourceVersion := object.GetResourceVersion() if resourceVersion == "" { resourceVersion = hash.ComputeTemplateHash(object) @@ -290,12 +272,6 @@ func compareResourceCacheObjectRefs(a, b ResourceCacheObjectRef) int { if c := strings.Compare(string(a.UID), string(b.UID)); c != 0 { return c } - if a.Generation != b.Generation { - if a.Generation < b.Generation { - return -1 - } - return 1 - } return strings.Compare(a.ResourceVersion, b.ResourceVersion) } @@ -325,25 +301,3 @@ func isNilResourceCacheObject[T client.Object](object T) bool { value := reflect.ValueOf(clientObject) return value.Kind() == reflect.Pointer && value.IsNil() } - -func isResourceCacheLookupObject(object client.Object) bool { - lookupObject, ok := object.DeepCopyObject().(client.Object) - if !ok { - return false - } - lookupObject.SetGenerateName(object.GetGenerateName()) - lookupObject.SetName("") - lookupObject.SetNamespace("") - lookupObject.SetResourceVersion("") - - objectValue := reflect.ValueOf(object) - if objectValue.Kind() != reflect.Pointer { - return false - } - zeroObject, ok := reflect.New(objectValue.Elem().Type()).Interface().(client.Object) - if !ok { - return false - } - - return hash.ComputeTemplateHash(lookupObject) == hash.ComputeTemplateHash(zeroObject) -} diff --git a/controllers/actions.github.com/resourcecache_test.go b/controllers/actions.github.com/resourcecache_test.go index aa54341a1d..e187ac5e0d 100644 --- a/controllers/actions.github.com/resourcecache_test.go +++ b/controllers/actions.github.com/resourcecache_test.go @@ -184,10 +184,9 @@ func TestResourceCacheIgnoresInvalidInputs(t *testing.T) { func TestResourceBuilderCachesListenerPodDependencies(t *testing.T) { listener := &v1alpha1.AutoscalingListener{ ObjectMeta: metav1.ObjectMeta{ - Name: "listener", - Namespace: "controller-ns", - UID: "listener-uid", - Annotations: map[string]string{"example.com/listener-hash": "listener-hash"}, + Name: "listener", + Namespace: "controller-ns", + UID: "listener-uid", }, Spec: v1alpha1.AutoscalingListenerSpec{ Image: "listener:latest", @@ -202,7 +201,6 @@ func TestResourceBuilderCachesListenerPodDependencies(t *testing.T) { Namespace: "controller-ns", UID: "config-secret-uid", ResourceVersion: "11", - Annotations: map[string]string{"example.com/config-hash": "config-hash"}, }, } serviceAccount := &corev1.ServiceAccount{ @@ -211,7 +209,6 @@ func TestResourceBuilderCachesListenerPodDependencies(t *testing.T) { Namespace: "controller-ns", UID: "service-account-uid", ResourceVersion: "12", - Annotations: map[string]string{"example.com/service-account-hash": "service-account-hash"}, }, } role := &rbacv1.Role{ @@ -220,7 +217,6 @@ func TestResourceBuilderCachesListenerPodDependencies(t *testing.T) { Namespace: "scale-set-ns", UID: "role-uid", ResourceVersion: "13", - Annotations: map[string]string{"example.com/role-hash": "role-hash"}, }, } roleBinding := &rbacv1.RoleBinding{ @@ -229,7 +225,6 @@ func TestResourceBuilderCachesListenerPodDependencies(t *testing.T) { Namespace: "scale-set-ns", UID: "role-binding-uid", ResourceVersion: "14", - Annotations: map[string]string{"example.com/role-binding-hash": "role-binding-hash"}, }, } @@ -238,62 +233,15 @@ func TestResourceBuilderCachesListenerPodDependencies(t *testing.T) { listenerPod, err := b.newScaleSetListenerPod(listener, podConfig, serviceAccount, role, roleBinding, nil) require.NoError(t, err) - metadataDependency := resourceCacheObjectMetadataInputObject(listener) - cachedPod, ok := b.ResourceCache.listenerPod.Get(listener, listenerPod, podConfig, serviceAccount, role, roleBinding, metadataDependency) + cachedPod, ok := b.ResourceCache.listenerPod.Get(listener, listenerPod, podConfig, serviceAccount, role, roleBinding) require.True(t, ok) assert.IsType(t, &corev1.Pod{}, cachedPod) - lookupPod := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: listenerPod.Name, - Namespace: listenerPod.Namespace, - }, - } - cachedPod, ok = b.ResourceCache.listenerPod.Get(listener, lookupPod, podConfig, serviceAccount, role, roleBinding, metadataDependency) - require.True(t, ok, "name-only lookup object should hit the cached desired pod") - assert.Same(t, listenerPod, cachedPod) - role.ResourceVersion = "changed" - _, ok = b.ResourceCache.listenerPod.Get(listener, lookupPod, podConfig, serviceAccount, role, roleBinding, metadataDependency) + _, ok = b.ResourceCache.listenerPod.Get(listener, listenerPod, podConfig, serviceAccount, role, roleBinding) assert.False(t, ok) } -func TestResourceBuilderCachesListenerPodMetadataDependency(t *testing.T) { - listener := &v1alpha1.AutoscalingListener{ - ObjectMeta: metav1.ObjectMeta{ - Name: "listener", - Namespace: "controller-ns", - UID: "listener-uid", - Labels: map[string]string{ - "arc.test/listener-label": "initial", - }, - }, - Spec: v1alpha1.AutoscalingListenerSpec{ - Image: "listener:latest", - AutoscalingRunnerSetName: "scale-set", - AutoscalingRunnerSetNamespace: "scale-set-ns", - EphemeralRunnerSetName: "scale-set", - }, - } - podConfig := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "listener-config", Namespace: "controller-ns", UID: "config-secret-uid", ResourceVersion: "11"}} - serviceAccount := &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: "listener", Namespace: "controller-ns", UID: "service-account-uid", ResourceVersion: "12"}} - role := &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: "listener", Namespace: "scale-set-ns", UID: "role-uid", ResourceVersion: "13"}} - roleBinding := &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "listener", Namespace: "scale-set-ns", UID: "role-binding-uid", ResourceVersion: "14"}} - - cache := NewResourceCache() - b := ResourceBuilder{ResourceCache: &cache} - listenerPod, err := b.newScaleSetListenerPod(listener, podConfig, serviceAccount, role, roleBinding, nil) - require.NoError(t, err) - - lookupPod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: listenerPod.Name, Namespace: listenerPod.Namespace}} - _, ok := b.ResourceCache.listenerPod.Get(listener, lookupPod, podConfig, serviceAccount, role, roleBinding, resourceCacheObjectMetadataInputObject(listener)) - assert.True(t, ok) - - listener.Labels["arc.test/listener-label"] = "updated" - _, ok = b.ResourceCache.listenerPod.Get(listener, lookupPod, podConfig, serviceAccount, role, roleBinding, resourceCacheObjectMetadataInputObject(listener)) - assert.False(t, ok, "cache miss when listener metadata used by the pod changes") -} - func TestResourceBuilderCachesEphemeralRunnerSet(t *testing.T) { autoscalingRunnerSet := v1alpha1.AutoscalingRunnerSet{ ObjectMeta: metav1.ObjectMeta{ @@ -314,184 +262,16 @@ func TestResourceBuilderCachesEphemeralRunnerSet(t *testing.T) { runnerSet, err := b.newEphemeralRunnerSet(&autoscalingRunnerSet) require.NoError(t, err) - metadataDependency := resourceCacheObjectMetadataInputObject(&autoscalingRunnerSet) - cachedRunnerSet, ok := b.ResourceCache.ephemeralRunnerSet.Get(&autoscalingRunnerSet, runnerSet, metadataDependency) - require.True(t, ok, "direct cache Get with returned object should hit") + cachedRunnerSet, ok := b.ResourceCache.ephemeralRunnerSet.Get(&autoscalingRunnerSet, runnerSet) + require.True(t, ok) assert.Equal(t, runnerSet.Spec, cachedRunnerSet.Spec) assert.Same(t, runnerSet, cachedRunnerSet) - lookupRunnerSet := &v1alpha1.EphemeralRunnerSet{ObjectMeta: metav1.ObjectMeta{Name: runnerSet.Name, Namespace: runnerSet.Namespace}} - cachedRunnerSet, ok = b.ResourceCache.ephemeralRunnerSet.Get(&autoscalingRunnerSet, lookupRunnerSet, metadataDependency) - require.True(t, ok, "name-only lookup object should hit the cached desired runner set") - assert.Same(t, runnerSet, cachedRunnerSet) - - autoscalingRunnerSet.Annotations[runnerScaleSetIDAnnotationKey] = "2" - _, ok = b.ResourceCache.ephemeralRunnerSet.Get(&autoscalingRunnerSet, lookupRunnerSet, metadataDependency) - assert.True(t, ok, "cache should be valid when main object generation unchanged") -} - -func TestResourceBuilderCachesEphemeralRunnerSetMetadataDependency(t *testing.T) { - autoscalingRunnerSet := v1alpha1.AutoscalingRunnerSet{ - ObjectMeta: metav1.ObjectMeta{ - Name: "scale-set", - Namespace: "default", - UID: "scale-set-uid", - Labels: map[string]string{ - "arc.test/scale-set-label": "initial", - }, - Annotations: map[string]string{ - runnerScaleSetIDAnnotationKey: "1", - }, - }, - Spec: v1alpha1.AutoscalingRunnerSetSpec{ - GitHubConfigUrl: "https://github.com/actions/actions-runner-controller", - }, - } - - cache := NewResourceCache() - b := ResourceBuilder{ResourceCache: &cache} - runnerSet, err := b.newEphemeralRunnerSet(&autoscalingRunnerSet) + fromBuilder, err := b.newEphemeralRunnerSet(&autoscalingRunnerSet) require.NoError(t, err) + assert.Same(t, runnerSet, fromBuilder) - lookupRunnerSet := &v1alpha1.EphemeralRunnerSet{ObjectMeta: metav1.ObjectMeta{Name: runnerSet.Name, Namespace: runnerSet.Namespace}} - _, ok := b.ResourceCache.ephemeralRunnerSet.Get(&autoscalingRunnerSet, lookupRunnerSet, resourceCacheObjectMetadataInputObject(&autoscalingRunnerSet)) - assert.True(t, ok) - - autoscalingRunnerSet.Labels["arc.test/scale-set-label"] = "updated" - _, ok = b.ResourceCache.ephemeralRunnerSet.Get(&autoscalingRunnerSet, lookupRunnerSet, resourceCacheObjectMetadataInputObject(&autoscalingRunnerSet)) - assert.False(t, ok, "cache miss when autoscaling runner set metadata used by the runner set changes") -} - -func TestResourceCacheOwnerGenerationDoesNotAffectCacheEntry(t *testing.T) { - mainObject := &v1alpha1.AutoscalingListener{ - ObjectMeta: metav1.ObjectMeta{ - Name: "listener", - Namespace: "controller-ns", - UID: "listener-uid", - Generation: 5, - }, - } - desiredPod := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "listener", - Namespace: "controller-ns", - }, - } - - cache := NewResourceCache() - _, replaced := cache.listenerPod.Upsert(mainObject, desiredPod) - assert.True(t, replaced) - _, ok := cache.listenerPod.Get(mainObject, desiredPod) - assert.True(t, ok) - - mainObjectCopy := mainObject.DeepCopy() - _, ok = cache.listenerPod.Get(mainObjectCopy, desiredPod) - assert.True(t, ok, "cache hit when owner generation unchanged") -} - -func TestResourceCacheOwnerGenerationChangeInvalidatesCacheEntry(t *testing.T) { - mainObject := &v1alpha1.AutoscalingListener{ - ObjectMeta: metav1.ObjectMeta{ - Name: "listener", - Namespace: "controller-ns", - UID: "listener-uid", - Generation: 5, - }, - } - desiredPod := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "listener", - Namespace: "controller-ns", - }, - } - - cache := NewResourceCache() - _, replaced := cache.listenerPod.Upsert(mainObject, desiredPod) - assert.True(t, replaced) - _, ok := cache.listenerPod.Get(mainObject, desiredPod) - assert.True(t, ok) - - mainObjectWithNewGeneration := mainObject.DeepCopy() - mainObjectWithNewGeneration.Generation = 6 - _, ok = cache.listenerPod.Get(mainObjectWithNewGeneration, desiredPod) - assert.False(t, ok, "cache miss when owner generation changes") -} - -func TestResourceCacheDependencyResourceVersionChangeInvalidates(t *testing.T) { - mainObject := &v1alpha1.AutoscalingListener{ - ObjectMeta: metav1.ObjectMeta{ - Name: "listener", - Namespace: "controller-ns", - UID: "listener-uid", - }, - } - desiredPod := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "listener", - Namespace: "controller-ns", - }, - } - dependency := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "config", - Namespace: "controller-ns", - UID: "config-uid", - ResourceVersion: "1", - }, - } - - cache := NewResourceCache() - _, replaced := cache.listenerPod.Upsert(mainObject, desiredPod, dependency) - assert.True(t, replaced) - _, ok := cache.listenerPod.Get(mainObject, desiredPod, dependency) - assert.True(t, ok) - - dependencyWithNewResourceVersion := dependency.DeepCopy() - dependencyWithNewResourceVersion.ResourceVersion = "2" - _, ok = cache.listenerPod.Get(mainObject, desiredPod, dependencyWithNewResourceVersion) - assert.False(t, ok, "cache miss when dependency resourceVersion changes") -} - -func TestResourceCacheNoAnnotationFallback(t *testing.T) { - mainObject := &v1alpha1.AutoscalingListener{ - ObjectMeta: metav1.ObjectMeta{ - Name: "listener", - Namespace: "controller-ns", - UID: "listener-uid", - }, - } - desiredPodWithoutResourceVersion := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "listener", - Namespace: "controller-ns", - }, - } - desiredPodWithDifferentAnnotation := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "listener", - Namespace: "controller-ns", - Annotations: map[string]string{ - "unrelated-key": "unrelated-value", - }, - }, - } - - cache := NewResourceCache() - _, replaced := cache.listenerPod.Upsert(mainObject, desiredPodWithoutResourceVersion) - assert.True(t, replaced) - - _, ok := cache.listenerPod.Get(mainObject, desiredPodWithoutResourceVersion) - assert.True(t, ok, "cache hit with same pod object") - - _, ok = cache.listenerPod.Get(mainObject, desiredPodWithDifferentAnnotation) - assert.False(t, ok, "cache miss when pod changed - uses hash not annotation fallback") - - desiredPodIdentical := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{ - Name: "listener", - Namespace: "controller-ns", - }, - } - _, ok = cache.listenerPod.Get(mainObject, desiredPodIdentical) - assert.True(t, ok, "cache hit when pod structure identical even if different instance") + autoscalingRunnerSet.Annotations[runnerScaleSetIDAnnotationKey] = "2" + _, ok = b.ResourceCache.ephemeralRunnerSet.Get(&autoscalingRunnerSet, runnerSet) + assert.False(t, ok) } From c08a1147d048e7a242d5fa448bbfda4ca94c8ba0 Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Wed, 22 Jul 2026 22:44:54 +0200 Subject: [PATCH 25/31] fix --- .../autoscalingrunnerset_controller.go | 8 +- .../ephemeralrunnerset_controller.go | 56 +------------ .../ephemeralrunnerset_controller_test.go | 78 ++++++++++++++----- 3 files changed, 62 insertions(+), 80 deletions(-) diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller.go b/controllers/actions.github.com/autoscalingrunnerset_controller.go index 0cbf1b49f8..a632c3f0bc 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller.go @@ -303,16 +303,12 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl ephemeralRunnerMetadataModified := !cmp.Equal(ephemeralRunnerSet.Spec.EphemeralRunnerMetadata, desired.Spec.EphemeralRunnerMetadata) ephemeralRunnerLabelsModified := !maps.Equal(ephemeralRunnerSet.Labels, desired.Labels) ephemeralRunnerAnnotationsModified := !maps.Equal(ephemeralRunnerSet.Annotations, desired.Annotations) - ephemeralRunnerReplicasModified := ephemeralRunnerSet.Spec.Replicas != desired.Spec.Replicas - ephemeralRunnerPatchIDModified := ephemeralRunnerSet.Spec.PatchID != desired.Spec.PatchID - if ephemeralRunnerLabelsModified || ephemeralRunnerAnnotationsModified || ephemeralRunnerMetadataModified || ephemeralRunnerReplicasModified || ephemeralRunnerPatchIDModified { + if ephemeralRunnerLabelsModified || ephemeralRunnerAnnotationsModified || ephemeralRunnerMetadataModified { original := ephemeralRunnerSet.DeepCopy() ephemeralRunnerSet.Labels = r.filterAndMergeLabels(ephemeralRunnerSet.Labels, desired.Labels) ephemeralRunnerSet.Annotations = desired.Annotations ephemeralRunnerSet.Spec.EphemeralRunnerMetadata = desired.Spec.EphemeralRunnerMetadata - ephemeralRunnerSet.Spec.Replicas = desired.Spec.Replicas - ephemeralRunnerSet.Spec.PatchID = desired.Spec.PatchID log.Info("Updating ephemeral runner set metadata to match desired labels and annotations") if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original)); err != nil { log.Error(err, "Failed to patch ephemeral runner set metadata to match desired labels and annotations") @@ -759,8 +755,6 @@ func (r *AutoscalingRunnerSetReconciler) createEphemeralRunnerSet(ctx context.Co log.Error(err, "Could not create EphemeralRunnerSet") return ctrl.Result{}, err } - desiredRunnerSet.Spec.ActionableRevision = 1 - log.Info("Creating a new EphemeralRunnerSet resource") if err := r.Create(ctx, desiredRunnerSet); err != nil { log.Error(err, "Failed to create EphemeralRunnerSet resource") diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller.go b/controllers/actions.github.com/ephemeralrunnerset_controller.go index 964c727196..e25959d97e 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller.go @@ -140,7 +140,7 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R "specActionableRevision", ephemeralRunnerSet.Spec.ActionableRevision, "statusAppliedActionableRevision", ephemeralRunnerSet.Status.AppliedActionableRevision, ) - if err := r.cleanUpIdleAndPendingEphemeralRunners(ctx, &ephemeralRunnerSet, log); err != nil { + if _, err := r.cleanUpEphemeralRunners(ctx, &ephemeralRunnerSet, log); err != nil { log.Error(err, "Failed to clean up EphemeralRunners") return ctrl.Result{}, err } @@ -422,60 +422,6 @@ func (r *EphemeralRunnerSetReconciler) cleanUpEphemeralRunners(ctx context.Conte return false, nil } -func (r *EphemeralRunnerSetReconciler) cleanUpIdleAndPendingEphemeralRunners(ctx context.Context, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, log logr.Logger) error { - ephemeralRunnerList := new(v1alpha1.EphemeralRunnerList) - err := r.List(ctx, ephemeralRunnerList, client.InNamespace(ephemeralRunnerSet.Namespace), client.MatchingFields{resourceOwnerKey: ephemeralRunnerSet.Name}) - if err != nil { - return fmt.Errorf("failed to list child ephemeral runners: %w", err) - } - - ephemeralRunnerState := newEphemeralRunnersByStates(ephemeralRunnerList) - if len(ephemeralRunnerState.running) == 0 && len(ephemeralRunnerState.pending) == 0 { - return nil - } - - actionsClient, err := r.GetActionsService(ctx, ephemeralRunnerSet) - if err != nil { - return err - } - - log.Info("Cleanup pending or idle ephemeral runners", "pending", len(ephemeralRunnerState.pending), "running", len(ephemeralRunnerState.running)) - var errs []error - for _, ephemeralRunner := range ephemeralRunnerState.pending { - log.Info("Removing the pending ephemeral runner from the service", "name", ephemeralRunner.Name) - _, err := r.deleteEphemeralRunnerWithActionsClient(ctx, ephemeralRunner, actionsClient, log) - if err != nil { - errs = append(errs, err) - } - } - - for _, ephemeralRunner := range ephemeralRunnerState.running { - if ephemeralRunner.HasJob() { - log.Info( - "Skipping ephemeral runner since it is running a job", - "name", ephemeralRunner.Name, - "workflowRunId", ephemeralRunner.Status.WorkflowRunID, - "jobId", ephemeralRunner.Status.JobID, - ) - continue - } - - log.Info("Removing the idle ephemeral runner from the service", "name", ephemeralRunner.Name) - _, err := r.deleteEphemeralRunnerWithActionsClient(ctx, ephemeralRunner, actionsClient, log) - if err != nil { - errs = append(errs, err) - } - } - - if len(errs) > 0 { - mergedErrs := multierr.Combine(errs...) - log.Error(mergedErrs, "Failed to remove idle or pending ephemeral runners from the service") - return mergedErrs - } - - return nil -} - func (r *EphemeralRunnerSetReconciler) cleanUpEphemeralRunnerSetProxySecret(ctx context.Context, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, log logr.Logger) (done bool, err error) { if ephemeralRunnerSet.Spec.EphemeralRunnerSpec.Proxy == nil { return true, nil diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go index 1d25203e07..87a93d1d08 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go @@ -1523,6 +1523,59 @@ var _ = Describe("Test EphemeralRunnerSet actionable revision cleanup", func() { startManagers(GinkgoT(), mgr) }) + It("does not clean up runners on initial creation without an actionable revision", func() { + controller := &EphemeralRunnerSetReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Log: logf.Log, + ResourceBuilder: ResourceBuilder{ + ResourceCache: newTestResourceCache(), + SecretResolver: secretresolver.New(mgr.GetClient(), fake.NewMultiClient( + fake.WithClient(fake.NewClient(fake.WithRemoveRunner(nil))), + )), + }, + } + + ephemeralRunnerSet := &v1alpha1.EphemeralRunnerSet{ + ObjectMeta: metav1.ObjectMeta{Name: "test-actionable-revision-initial", Namespace: autoscalingNS.Name}, + Spec: v1alpha1.EphemeralRunnerSetSpec{ + EphemeralRunnerSpec: v1alpha1.EphemeralRunnerSpec{ + GitHubConfigURL: "https://github.com/owner/repo", + GitHubConfigSecret: configSecret.Name, + RunnerScaleSetID: 100, + PodTemplateSpec: corev1.PodTemplateSpec{Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "runner", Image: "ghcr.io/actions/runner"}}}}, + }, + }, + } + + err := k8sClient.Create(ctx, ephemeralRunnerSet) + Expect(err).NotTo(HaveOccurred()) + + request := ctrl.Request{NamespacedName: types.NamespacedName{Name: ephemeralRunnerSet.Name, Namespace: ephemeralRunnerSet.Namespace}} + _, err = controller.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + + pendingRunner := newRunner("runner-pending-initial", ephemeralRunnerSet) + err = k8sClient.Create(ctx, pendingRunner) + Expect(err).NotTo(HaveOccurred()) + + _, err = controller.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + + Consistently(func() error { + runner := new(v1alpha1.EphemeralRunner) + return k8sClient.Get(ctx, types.NamespacedName{Namespace: autoscalingNS.Name, Name: pendingRunner.Name}, runner) + }, time.Second, ephemeralRunnerSetTestInterval).Should(Succeed()) + + Consistently(func() int64 { + updatedSet := new(v1alpha1.EphemeralRunnerSet) + if err := k8sClient.Get(ctx, request.NamespacedName, updatedSet); err != nil { + return -1 + } + return updatedSet.Status.AppliedActionableRevision + }, time.Second, ephemeralRunnerSetTestInterval).Should(Equal(int64(0))) + }) + It("deletes runner-a-idle, keeps runner-b-busy, and advances applied actionable revision 3 to 4", func() { controller := &EphemeralRunnerSetReconciler{ Client: mgr.GetClient(), @@ -1698,7 +1751,7 @@ var _ = Describe("Test EphemeralRunnerSet actionable revision cleanup", func() { }, time.Second, ephemeralRunnerSetTestInterval).Should(Equal(int64(3))) }) - It("deletes idle runner and advances revision after restart with no cache", func() { + It("does not delete unregistered pending runner after restart with no cache", func() { controller := &EphemeralRunnerSetReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), @@ -1738,31 +1791,20 @@ var _ = Describe("Test EphemeralRunnerSet actionable revision cleanup", func() { err = k8sClient.Status().Patch(ctx, statusUpdated, client.MergeFrom(current)) Expect(err).NotTo(HaveOccurred()) - // Create idle runner (running but no job) - idleRunner := newRunner("runner-restart-idle", statusUpdated) - err = k8sClient.Create(ctx, idleRunner) - Expect(err).NotTo(HaveOccurred()) - - idleCurrent := new(v1alpha1.EphemeralRunner) - err = k8sClient.Get(ctx, client.ObjectKeyFromObject(idleRunner), idleCurrent) - Expect(err).NotTo(HaveOccurred()) - idleUpdated := idleCurrent.DeepCopy() - idleUpdated.Status.Phase = v1alpha1.EphemeralRunnerPhaseRunning - idleUpdated.Status.RunnerID = 201 - err = k8sClient.Status().Patch(ctx, idleUpdated, client.MergeFrom(idleCurrent)) + pendingRunner := newRunner("runner-restart-pending", statusUpdated) + err = k8sClient.Create(ctx, pendingRunner) Expect(err).NotTo(HaveOccurred()) // Reconcile with fresh cache (simulating restart) _, err = controller.Reconcile(ctx, request) Expect(err).NotTo(HaveOccurred()) - // Idle runner should be deleted - Eventually(func() bool { + Consistently(func() error { runner := new(v1alpha1.EphemeralRunner) - return kerrors.IsNotFound(k8sClient.Get(ctx, types.NamespacedName{Namespace: autoscalingNS.Name, Name: "runner-restart-idle"}, runner)) - }, ephemeralRunnerSetTestTimeout, ephemeralRunnerSetTestInterval).Should(BeTrue()) + return k8sClient.Get(ctx, types.NamespacedName{Namespace: autoscalingNS.Name, Name: "runner-restart-pending"}, runner) + }, time.Second, ephemeralRunnerSetTestInterval).Should(Succeed()) - // AppliedActionableRevision should advance to 4 + // AppliedActionableRevision should advance because there was nothing safe to clean up. Eventually(func() int64 { updatedSet := new(v1alpha1.EphemeralRunnerSet) if err := k8sClient.Get(ctx, request.NamespacedName, updatedSet); err != nil { From 73c8f52c269eb4fe0a5d404d89cf912099985823 Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Thu, 23 Jul 2026 11:35:56 +0200 Subject: [PATCH 26/31] revert charts --- .github/workflows/arc-publish-chart.yaml | 2 +- .github/workflows/arc-validate-chart.yaml | 2 +- .github/workflows/gha-publish-chart.yaml | 16 ++++++++-------- .github/workflows/gha-validate-chart.yaml | 4 ++-- .github/workflows/global-publish-canary.yaml | 8 ++++---- .github/workflows/go.yaml | 2 +- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/arc-publish-chart.yaml b/.github/workflows/arc-publish-chart.yaml index 9e4fde5ada..5d1983a246 100644 --- a/.github/workflows/arc-publish-chart.yaml +++ b/.github/workflows/arc-publish-chart.yaml @@ -45,7 +45,7 @@ jobs: fetch-depth: 0 - name: Set up Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 with: version: ${{ env.HELM_VERSION }} diff --git a/.github/workflows/arc-validate-chart.yaml b/.github/workflows/arc-validate-chart.yaml index f905306c5a..e9ea3ba0dc 100644 --- a/.github/workflows/arc-validate-chart.yaml +++ b/.github/workflows/arc-validate-chart.yaml @@ -45,7 +45,7 @@ jobs: fetch-depth: 0 - name: Set up Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 with: version: ${{ env.HELM_VERSION }} diff --git a/.github/workflows/gha-publish-chart.yaml b/.github/workflows/gha-publish-chart.yaml index af42962ba1..8bcb407670 100644 --- a/.github/workflows/gha-publish-chart.yaml +++ b/.github/workflows/gha-publish-chart.yaml @@ -82,10 +82,10 @@ jobs: echo "repository_owner=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - name: Set up QEMU - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c with: # Pinning v0.9.1 for Buildx and BuildKit v0.10.6 # BuildKit v0.11 which has a bug causing intermittent @@ -94,14 +94,14 @@ jobs: driver-opts: image=moby/buildkit:v0.10.6 - name: Login to GitHub Container Registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build & push controller image - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a with: context: . file: Dockerfile @@ -149,7 +149,7 @@ jobs: echo "repository_owner=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - name: Set up Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 with: version: ${{ env.HELM_VERSION }} @@ -197,7 +197,7 @@ jobs: echo "repository_owner=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - name: Set up Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 with: version: ${{ env.HELM_VERSION }} @@ -244,7 +244,7 @@ jobs: echo "repository_owner=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - name: Set up Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 with: version: ${{ env.HELM_VERSION }} @@ -293,7 +293,7 @@ jobs: echo "repository_owner=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - name: Set up Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 with: version: ${{ env.HELM_VERSION }} diff --git a/.github/workflows/gha-validate-chart.yaml b/.github/workflows/gha-validate-chart.yaml index 949432ce96..8e6ed541c4 100644 --- a/.github/workflows/gha-validate-chart.yaml +++ b/.github/workflows/gha-validate-chart.yaml @@ -41,7 +41,7 @@ jobs: fetch-depth: 0 - name: Set up Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 with: version: ${{ env.HELM_VERSION }} @@ -79,7 +79,7 @@ jobs: fetch-depth: 0 - name: Set up Helm - uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 with: version: ${{ env.HELM_VERSION }} diff --git a/.github/workflows/global-publish-canary.yaml b/.github/workflows/global-publish-canary.yaml index 7adce96677..0a1775650a 100644 --- a/.github/workflows/global-publish-canary.yaml +++ b/.github/workflows/global-publish-canary.yaml @@ -93,7 +93,7 @@ jobs: uses: actions/checkout@v7 - name: Login to GitHub Container Registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 with: registry: ghcr.io username: ${{ github.actor }} @@ -110,16 +110,16 @@ jobs: echo "repository_owner=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT - name: Set up QEMU - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c with: version: latest # Unstable builds - run at your own risk - name: Build and Push - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a with: context: . file: ./Dockerfile diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml index 6a30459fd6..402b6c2a82 100644 --- a/.github/workflows/go.yaml +++ b/.github/workflows/go.yaml @@ -48,7 +48,7 @@ jobs: go-version-file: "go.mod" cache: false - name: golangci-lint - uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a with: only-new-issues: true version: v2.11.2 From 3a67a4fa3375e9b0e8f47a36593b0dbd3cf4c58d Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Thu, 23 Jul 2026 15:03:29 +0200 Subject: [PATCH 27/31] ok --- .../v1alpha1/ephemeralrunnerset_types.go | 6 + ...ctions.github.com_ephemeralrunnersets.yaml | 7 + ...ctions.github.com_ephemeralrunnersets.yaml | 7 + ...ctions.github.com_ephemeralrunnersets.yaml | 7 + .../ephemeralrunnerset_controller.go | 49 ++++- .../ephemeralrunnerset_controller_test.go | 174 +++++++++++++++++- 6 files changed, 233 insertions(+), 17 deletions(-) diff --git a/apis/actions.github.com/v1alpha1/ephemeralrunnerset_types.go b/apis/actions.github.com/v1alpha1/ephemeralrunnerset_types.go index bfa02d1dbf..22ed2dd9fe 100644 --- a/apis/actions.github.com/v1alpha1/ephemeralrunnerset_types.go +++ b/apis/actions.github.com/v1alpha1/ephemeralrunnerset_types.go @@ -52,6 +52,12 @@ type EphemeralRunnerSetStatus struct { // Unset defaults to 0. // +optional AppliedActionableRevision int64 `json:"appliedActionableRevision,omitempty"` + // FinishedRunnerCleanupPatchID records the listener patch ID for which finished + // ephemeral runners were cleaned up. Scale-up is suppressed for the same patch ID + // until the listener publishes a fresh desired-state patch. + // Unset defaults to 0. + // +optional + FinishedRunnerCleanupPatchID int `json:"finishedRunnerCleanupPatchID,omitempty"` } // EphemeralRunnerSetPhase is the phase of the ephemeral runner set resource diff --git a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml index cad0714011..f696ff054d 100644 --- a/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunnersets.yaml @@ -8309,6 +8309,13 @@ spec: Unset defaults to 0. format: int64 type: integer + finishedRunnerCleanupPatchID: + description: |- + FinishedRunnerCleanupPatchID records the listener patch ID for which finished + ephemeral runners were cleaned up. Scale-up is suppressed for the same patch ID + until the listener publishes a fresh desired-state patch. + Unset defaults to 0. + type: integer phase: description: EphemeralRunnerSetPhase is the phase of the ephemeral runner set resource type: string diff --git a/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml b/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml index cad0714011..f696ff054d 100644 --- a/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml +++ b/charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunnersets.yaml @@ -8309,6 +8309,13 @@ spec: Unset defaults to 0. format: int64 type: integer + finishedRunnerCleanupPatchID: + description: |- + FinishedRunnerCleanupPatchID records the listener patch ID for which finished + ephemeral runners were cleaned up. Scale-up is suppressed for the same patch ID + until the listener publishes a fresh desired-state patch. + Unset defaults to 0. + type: integer phase: description: EphemeralRunnerSetPhase is the phase of the ephemeral runner set resource type: string diff --git a/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml b/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml index cad0714011..f696ff054d 100644 --- a/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml +++ b/config/crd/bases/actions.github.com_ephemeralrunnersets.yaml @@ -8309,6 +8309,13 @@ spec: Unset defaults to 0. format: int64 type: integer + finishedRunnerCleanupPatchID: + description: |- + FinishedRunnerCleanupPatchID records the listener patch ID for which finished + ephemeral runners were cleaned up. Scale-up is suppressed for the same patch ID + until the listener publishes a fresh desired-state patch. + Unset defaults to 0. + type: integer phase: description: EphemeralRunnerSetPhase is the phase of the ephemeral runner set resource type: string diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller.go b/controllers/actions.github.com/ephemeralrunnerset_controller.go index e25959d97e..d979813732 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller.go @@ -201,15 +201,31 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R total := ephemeralRunnersByState.scaleTotal() if ephemeralRunnerSet.Spec.PatchID == 0 || ephemeralRunnerSet.Spec.PatchID != ephemeralRunnersByState.latestPatchID { - defer func() { + if len(ephemeralRunnersByState.finished) > 0 { if err := r.cleanupFinishedEphemeralRunners(ctx, ephemeralRunnersByState.finished, log); err != nil { log.Error(err, "failed to cleanup finished ephemeral runners") + return ctrl.Result{}, err + } + if err := r.patchFinishedRunnerCleanupPatchIDStatus(ctx, req.NamespacedName, ephemeralRunnerSet.Spec.PatchID); err != nil { + log.Error(err, "failed to update finished runner cleanup patch ID status") + return ctrl.Result{}, err } - }() - log.Info("Scaling comparison", "current", total, "desired", ephemeralRunnerSet.Spec.Replicas) + ephemeralRunnerSet.Status.FinishedRunnerCleanupPatchID = ephemeralRunnerSet.Spec.PatchID + + log.Info("Finished ephemeral runners were cleaned up, deferring scaling decision") + return ctrl.Result{}, r.updateStatus(ctx, &ephemeralRunnerSet, ephemeralRunnersByState, log) + } + + scaleUpTotal := total + len(ephemeralRunnersByState.deleting) + log.Info("Scaling comparison", "current", total, "deleting", len(ephemeralRunnersByState.deleting), "desired", ephemeralRunnerSet.Spec.Replicas) switch { - case total < ephemeralRunnerSet.Spec.Replicas: // Handle scale up - count := ephemeralRunnerSet.Spec.Replicas - total + case scaleUpTotal < ephemeralRunnerSet.Spec.Replicas: // Handle scale up + if ephemeralRunnerSet.Spec.PatchID > 0 && ephemeralRunnerSet.Status.FinishedRunnerCleanupPatchID == ephemeralRunnerSet.Spec.PatchID { + log.Info("Skipping scale up until listener publishes a fresh desired state after finished runner cleanup", "patchID", ephemeralRunnerSet.Spec.PatchID) + return ctrl.Result{}, r.updateStatus(ctx, &ephemeralRunnerSet, ephemeralRunnersByState, log) + } + + count := ephemeralRunnerSet.Spec.Replicas - scaleUpTotal log.Info("Creating new ephemeral runners (scale up)", "count", count) if err := r.createEphemeralRunners(ctx, &ephemeralRunnerSet, count, log); err != nil { log.Error(err, "failed to make ephemeral runner") @@ -268,6 +284,24 @@ func (r *EphemeralRunnerSetReconciler) patchAppliedActionableRevisionStatus(ctx }) } +func (r *EphemeralRunnerSetReconciler) patchFinishedRunnerCleanupPatchIDStatus(ctx context.Context, key types.NamespacedName, patchID int) error { + return retry.RetryOnConflict(retry.DefaultBackoff, func() error { + var latest v1alpha1.EphemeralRunnerSet + if err := r.Get(ctx, key, &latest); err != nil { + return err + } + + original := latest.DeepCopy() + latest.Status.FinishedRunnerCleanupPatchID = patchID + + if original.Status == latest.Status { + return nil + } + + return r.Status().Patch(ctx, &latest, client.MergeFrom(original)) + }) +} + func (r *EphemeralRunnerSetReconciler) updateStatus(ctx context.Context, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, state *ephemeralRunnersByState, log logr.Logger) error { original := ephemeralRunnerSet.DeepCopy() var phase v1alpha1.EphemeralRunnerSetPhase @@ -280,8 +314,9 @@ func (r *EphemeralRunnerSetReconciler) updateStatus(ctx context.Context, ephemer phase = ephemeralRunnerSet.Status.Phase } desiredStatus := v1alpha1.EphemeralRunnerSetStatus{ - Phase: phase, - AppliedActionableRevision: ephemeralRunnerSet.Status.AppliedActionableRevision, + Phase: phase, + AppliedActionableRevision: ephemeralRunnerSet.Status.AppliedActionableRevision, + FinishedRunnerCleanupPatchID: ephemeralRunnerSet.Status.FinishedRunnerCleanupPatchID, } // Update the status if needed. diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go index 87a93d1d08..5e53bf0eb7 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go @@ -627,7 +627,7 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { // confirm they are not deleted runnerList = new(v1alpha1.EphemeralRunnerList) - Consistently( + Eventually( func() (int, error) { err := listEphemeralRunnersAndRemoveFinalizers(ctx, k8sClient, runnerList, ephemeralRunnerSet.Namespace) if err != nil { @@ -690,16 +690,37 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { Expect(err).NotTo(HaveOccurred(), "failed to update EphemeralRunnerSet") runnerList = new(v1alpha1.EphemeralRunnerList) - // We should have 3 runners, and have no Succeeded ones Eventually( - func() error { + func() (int, error) { err := listEphemeralRunnersAndRemoveFinalizers(ctx, k8sClient, runnerList, ephemeralRunnerSet.Namespace) if err != nil { - return err + return -1, err } - if len(runnerList.Items) != 3 { - return fmt.Errorf("Expected 3 runners, got %d", len(runnerList.Items)) + return len(runnerList.Items), nil + }, + ephemeralRunnerSetTestTimeout, + ephemeralRunnerSetTestInterval, + ).Should(BeEquivalentTo(1), "only the running EphemeralRunner should remain before listener confirms the larger desired count") + + ers = new(v1alpha1.EphemeralRunnerSet) + err = k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunnerSet.Name, Namespace: ephemeralRunnerSet.Namespace}, ers) + Expect(err).NotTo(HaveOccurred(), "failed to get EphemeralRunnerSet") + + updated = ers.DeepCopy() + updated.Spec.Replicas = 3 + updated.Spec.PatchID = 3 + + err = k8sClient.Patch(ctx, updated, client.MergeFrom(ers)) + Expect(err).NotTo(HaveOccurred(), "failed to update EphemeralRunnerSet") + + runnerList = new(v1alpha1.EphemeralRunnerList) + // We should have 3 runners, and have no Succeeded ones after listener confirms. + Eventually( + func() error { + err := listEphemeralRunnersAndRemoveFinalizers(ctx, k8sClient, runnerList, ephemeralRunnerSet.Namespace) + if err != nil { + return err } for _, runner := range runnerList.Items { @@ -708,6 +729,10 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { } } + if len(runnerList.Items) != 3 { + return fmt.Errorf("Expected 3 runners, got %d", len(runnerList.Items)) + } + return nil }, ephemeralRunnerSetTestTimeout, @@ -1017,7 +1042,7 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { } } - if succeeded != 1 && running != 1 { + if succeeded != 1 || running != 1 { return fmt.Errorf("Expected 1 runner in Succeeded and 1 in Running, got %d in Succeeded and %d in Running", succeeded, running) } @@ -1027,8 +1052,9 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { ephemeralRunnerSetTestInterval, ).Should(BeNil(), "1 EphemeralRunner should be in Succeeded and 1 in Running phase") - // Now, let's simulate replacement. The desired count is still 2. - // This simulates that we got 1 job assigned, and 1 job completed. + // Now, let's simulate the listener publishing a stale patch before it has + // accounted for the completed job. The controller should clean up the + // finished runner but not create a replacement for this patch. ers = new(v1alpha1.EphemeralRunnerSet) err = k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunnerSet.Name, Namespace: ephemeralRunnerSet.Namespace}, ers) @@ -1041,6 +1067,33 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { err = k8sClient.Patch(ctx, updated, client.MergeFrom(ers)) Expect(err).NotTo(HaveOccurred(), "failed to update EphemeralRunnerSet") + runnerList = new(v1alpha1.EphemeralRunnerList) + Consistently( + func() (int, error) { + err := listEphemeralRunnersAndRemoveFinalizers(ctx, k8sClient, runnerList, ephemeralRunnerSet.Namespace) + if err != nil { + return -1, err + } + + return len(runnerList.Items), nil + }, + 2*time.Second, + ephemeralRunnerSetTestInterval, + ).Should(BeEquivalentTo(1), "only the running EphemeralRunner should remain before listener confirms replacement") + + // A fresh listener decision with the same desired count confirms that a + // replacement is still needed. + ers = new(v1alpha1.EphemeralRunnerSet) + err = k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunnerSet.Name, Namespace: ephemeralRunnerSet.Namespace}, ers) + Expect(err).NotTo(HaveOccurred(), "failed to get EphemeralRunnerSet") + + updated = ers.DeepCopy() + updated.Spec.Replicas = 2 + updated.Spec.PatchID = 3 + + err = k8sClient.Patch(ctx, updated, client.MergeFrom(ers)) + Expect(err).NotTo(HaveOccurred(), "failed to update EphemeralRunnerSet") + runnerList = new(v1alpha1.EphemeralRunnerList) Eventually( func() error { @@ -1066,6 +1119,107 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { ).Should(BeNil(), "2 EphemeralRunner should be created and none should be in Succeeded phase") }) + It("Should not create a replacement when a runner finishes ahead of the listener decrement patch", func() { + ers := new(v1alpha1.EphemeralRunnerSet) + err := k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunnerSet.Name, Namespace: ephemeralRunnerSet.Namespace}, ers) + Expect(err).NotTo(HaveOccurred(), "failed to get EphemeralRunnerSet") + + updated := ers.DeepCopy() + updated.Spec.Replicas = 4 + updated.Spec.PatchID = 1 + + err = k8sClient.Patch(ctx, updated, client.MergeFrom(ers)) + Expect(err).NotTo(HaveOccurred(), "failed to update EphemeralRunnerSet") + + runnerList := new(v1alpha1.EphemeralRunnerList) + Eventually( + func() (int, error) { + err := listEphemeralRunnersAndRemoveFinalizers(ctx, k8sClient, runnerList, ephemeralRunnerSet.Namespace) + if err != nil { + return -1, err + } + + return len(runnerList.Items), nil + }, + ephemeralRunnerSetTestTimeout, + ephemeralRunnerSetTestInterval, + ).Should(BeEquivalentTo(4), "4 EphemeralRunner should be created") + + for i := range 3 { + updatedRunner := runnerList.Items[i].DeepCopy() + updatedRunner.Status.Phase = v1alpha1.EphemeralRunnerPhaseRunning + err = k8sClient.Status().Patch(ctx, updatedRunner, client.MergeFrom(&runnerList.Items[i])) + Expect(err).NotTo(HaveOccurred(), "failed to update EphemeralRunner") + } + + updatedRunner := runnerList.Items[3].DeepCopy() + updatedRunner.Status.Phase = v1alpha1.EphemeralRunnerPhaseSucceeded + err = k8sClient.Status().Patch(ctx, updatedRunner, client.MergeFrom(&runnerList.Items[3])) + Expect(err).NotTo(HaveOccurred(), "failed to update EphemeralRunner") + + ers = new(v1alpha1.EphemeralRunnerSet) + err = k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunnerSet.Name, Namespace: ephemeralRunnerSet.Namespace}, ers) + Expect(err).NotTo(HaveOccurred(), "failed to get EphemeralRunnerSet") + + updated = ers.DeepCopy() + updated.Spec.Replicas = 4 + updated.Spec.PatchID = 2 + + err = k8sClient.Patch(ctx, updated, client.MergeFrom(ers)) + Expect(err).NotTo(HaveOccurred(), "failed to update EphemeralRunnerSet") + + Consistently( + func() (int, error) { + err := listEphemeralRunnersAndRemoveFinalizers(ctx, k8sClient, runnerList, ephemeralRunnerSet.Namespace) + if err != nil { + return -1, err + } + + return len(runnerList.Items), nil + }, + ephemeralRunnerSetTestTimeout, + ephemeralRunnerSetTestInterval, + ).Should(BeEquivalentTo(3), "only the running EphemeralRunners should remain after stale-patch cleanup") + + Consistently( + func() (int, error) { + err := listEphemeralRunnersAndRemoveFinalizers(ctx, k8sClient, runnerList, ephemeralRunnerSet.Namespace) + if err != nil { + return -1, err + } + + return len(runnerList.Items), nil + }, + 12*time.Second, + ephemeralRunnerSetTestInterval, + ).Should(BeEquivalentTo(3), "EphemeralRunnerSet should not create a replacement before listener decrements") + + ers = new(v1alpha1.EphemeralRunnerSet) + err = k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunnerSet.Name, Namespace: ephemeralRunnerSet.Namespace}, ers) + Expect(err).NotTo(HaveOccurred(), "failed to get EphemeralRunnerSet") + + updated = ers.DeepCopy() + updated.Spec.Replicas = 3 + updated.Spec.PatchID = 3 + + err = k8sClient.Patch(ctx, updated, client.MergeFrom(ers)) + Expect(err).NotTo(HaveOccurred(), "failed to update EphemeralRunnerSet") + + runnerList = new(v1alpha1.EphemeralRunnerList) + Eventually( + func() (int, error) { + err := listEphemeralRunnersAndRemoveFinalizers(ctx, k8sClient, runnerList, ephemeralRunnerSet.Namespace) + if err != nil { + return -1, err + } + + return len(runnerList.Items), nil + }, + ephemeralRunnerSetTestTimeout, + ephemeralRunnerSetTestInterval, + ).Should(BeEquivalentTo(3), "EphemeralRunnerSet should converge after listener decrements") + }) + It("Should delete idle runners, keep busy runners, and create new runners when the spec changes", func() { ers := new(v1alpha1.EphemeralRunnerSet) err := k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunnerSet.Name, Namespace: ephemeralRunnerSet.Namespace}, ers) @@ -1092,7 +1246,7 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() { ).Should(BeEquivalentTo(3), "3 EphemeralRunner should be created") idleRunnerNames := map[string]struct{}{} - for i := 0; i < 2; i++ { + for i := range 2 { idleRunner := runnerList.Items[i].DeepCopy() idleRunner.Status.Phase = v1alpha1.EphemeralRunnerPhaseRunning idleRunner.Status.RunnerID = i + 101 From f2fbfe357201a6d5053f9dd29b322cf22a2faa2a Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Thu, 23 Jul 2026 17:19:27 +0200 Subject: [PATCH 28/31] fix test --- .../ephemeralrunnerset_controller_test.go | 71 ++++++++++++++----- 1 file changed, 55 insertions(+), 16 deletions(-) diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go index 5e53bf0eb7..6f9942e6dd 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go @@ -1808,20 +1808,32 @@ var _ = Describe("Test EphemeralRunnerSet actionable revision cleanup", func() { err = k8sClient.Patch(ctx, specUpdated, client.MergeFrom(current)) Expect(err).NotTo(HaveOccurred()) - _, err = controller.Reconcile(ctx, request) - Expect(err).NotTo(HaveOccurred()) - Eventually(func() bool { + _, err := controller.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + runner := new(v1alpha1.EphemeralRunner) return kerrors.IsNotFound(k8sClient.Get(ctx, types.NamespacedName{Namespace: autoscalingNS.Name, Name: "runner-a-idle"}, runner)) }, ephemeralRunnerSetTestTimeout, ephemeralRunnerSetTestInterval).Should(BeTrue()) Consistently(func() error { runner := new(v1alpha1.EphemeralRunner) - return k8sClient.Get(ctx, types.NamespacedName{Namespace: autoscalingNS.Name, Name: "runner-b-busy"}, runner) + if err := k8sClient.Get(ctx, types.NamespacedName{Namespace: autoscalingNS.Name, Name: "runner-b-busy"}, runner); err != nil { + return err + } + if runner.Status.RunnerID != 102 { + return fmt.Errorf("expected busy runner ID 102, got %d", runner.Status.RunnerID) + } + if !runner.HasJob() { + return fmt.Errorf("expected runner-b-busy to keep its assigned job") + } + return nil }, time.Second, ephemeralRunnerSetTestInterval).Should(Succeed()) Eventually(func() int64 { + _, err := controller.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + updatedSet := new(v1alpha1.EphemeralRunnerSet) if err := k8sClient.Get(ctx, request.NamespacedName, updatedSet); err != nil { return 0 @@ -1905,7 +1917,7 @@ var _ = Describe("Test EphemeralRunnerSet actionable revision cleanup", func() { }, time.Second, ephemeralRunnerSetTestInterval).Should(Equal(int64(3))) }) - It("does not delete unregistered pending runner after restart with no cache", func() { + It("deletes unregistered pending runner during actionable revision cleanup after restart with no cache", func() { controller := &EphemeralRunnerSetReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), @@ -1949,17 +1961,33 @@ var _ = Describe("Test EphemeralRunnerSet actionable revision cleanup", func() { err = k8sClient.Create(ctx, pendingRunner) Expect(err).NotTo(HaveOccurred()) - // Reconcile with fresh cache (simulating restart) - _, err = controller.Reconcile(ctx, request) - Expect(err).NotTo(HaveOccurred()) + Eventually(func(g Gomega) { + cachedSet := new(v1alpha1.EphemeralRunnerSet) + err := controller.Get(ctx, request.NamespacedName, cachedSet) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cachedSet.Status.AppliedActionableRevision).To(Equal(int64(3))) + + cachedRunner := new(v1alpha1.EphemeralRunner) + err = controller.Get(ctx, types.NamespacedName{Namespace: autoscalingNS.Name, Name: "runner-restart-pending"}, cachedRunner) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cachedRunner.Status.RunnerID).To(BeZero()) + g.Expect(cachedRunner.Status.Phase).To(BeEmpty()) + }, ephemeralRunnerSetTestTimeout, ephemeralRunnerSetTestInterval).Should(Succeed()) + + // Reconcile with fresh cache (simulating restart). Actionable revision cleanup deletes pending runners. + Eventually(func() bool { + _, err := controller.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) - Consistently(func() error { runner := new(v1alpha1.EphemeralRunner) - return k8sClient.Get(ctx, types.NamespacedName{Namespace: autoscalingNS.Name, Name: "runner-restart-pending"}, runner) - }, time.Second, ephemeralRunnerSetTestInterval).Should(Succeed()) + return kerrors.IsNotFound(k8sClient.Get(ctx, types.NamespacedName{Namespace: autoscalingNS.Name, Name: "runner-restart-pending"}, runner)) + }, ephemeralRunnerSetTestTimeout, ephemeralRunnerSetTestInterval).Should(BeTrue()) - // AppliedActionableRevision should advance because there was nothing safe to clean up. + // AppliedActionableRevision should advance after cleanup completes. Eventually(func() int64 { + _, err := controller.Reconcile(ctx, request) + Expect(err).NotTo(HaveOccurred()) + updatedSet := new(v1alpha1.EphemeralRunnerSet) if err := k8sClient.Get(ctx, request.NamespacedName, updatedSet); err != nil { return 0 @@ -2050,14 +2078,25 @@ var _ = Describe("Test EphemeralRunnerSet actionable revision cleanup", func() { err = k8sClient.Status().Patch(ctx, runnerStatusUpdated, client.MergeFrom(ephemeralRunner)) Expect(err).NotTo(HaveOccurred()) - // Reconcile - should detect outdated runner and change phase to Outdated - _, err = controller.Reconcile(ctx, request) - Expect(err).NotTo(HaveOccurred()) + Eventually(func(g Gomega) { + cachedSet := new(v1alpha1.EphemeralRunnerSet) + err := controller.Get(ctx, request.NamespacedName, cachedSet) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cachedSet.Status.AppliedActionableRevision).To(Equal(int64(5))) + + cachedRunner := new(v1alpha1.EphemeralRunner) + err = controller.Get(ctx, types.NamespacedName{Namespace: autoscalingNS.Name, Name: "test-runner-outdated"}, cachedRunner) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(cachedRunner.Status.Phase).To(Equal(v1alpha1.EphemeralRunnerPhaseOutdated)) + }, ephemeralRunnerSetTestTimeout, ephemeralRunnerSetTestInterval).Should(Succeed()) // Verify: Phase changed to Outdated, but AppliedActionableRevision preserved Eventually(func(g Gomega) { + _, err := controller.Reconcile(ctx, request) + g.Expect(err).NotTo(HaveOccurred()) + updatedSet := new(v1alpha1.EphemeralRunnerSet) - err := k8sClient.Get(ctx, request.NamespacedName, updatedSet) + err = k8sClient.Get(ctx, request.NamespacedName, updatedSet) g.Expect(err).NotTo(HaveOccurred()) g.Expect(updatedSet.Status.Phase).To(Equal(v1alpha1.EphemeralRunnerSetPhaseOutdated), "phase should change to Outdated") g.Expect(updatedSet.Status.AppliedActionableRevision).To(Equal(int64(5)), "AppliedActionableRevision should be preserved") From ac2864816d1a8bf40c05de0ea834cf09b1283c99 Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Fri, 17 Jul 2026 16:15:58 +0200 Subject: [PATCH 29/31] wip --- apis/actions.github.com/v1alpha1/version.go | 2 +- .../autoscalinglistener_controller.go | 153 ++++++++++-------- .../autoscalingrunnerset_controller.go | 108 ++++++++----- .../ephemeralrunner_controller.go | 56 ++++--- .../ephemeralrunnerset_controller.go | 61 ++++--- .../actions.github.com/resourcebuilder.go | 1 - controllers/actions.github.com/utils.go | 27 ++-- controllers/actions.github.com/utils_test.go | 12 ++ 8 files changed, 263 insertions(+), 157 deletions(-) diff --git a/apis/actions.github.com/v1alpha1/version.go b/apis/actions.github.com/v1alpha1/version.go index 731c601151..4a3dc33284 100644 --- a/apis/actions.github.com/v1alpha1/version.go +++ b/apis/actions.github.com/v1alpha1/version.go @@ -3,7 +3,7 @@ package v1alpha1 import "strings" func IsVersionAllowed(resourceVersion, buildVersion string) bool { - if buildVersion == "dev" || resourceVersion == buildVersion || strings.HasPrefix(buildVersion, "canary-") { + if resourceVersion == buildVersion || buildVersion == "dev" || strings.HasPrefix(buildVersion, "canary-") { return true } diff --git a/controllers/actions.github.com/autoscalinglistener_controller.go b/controllers/actions.github.com/autoscalinglistener_controller.go index 266d9d60f3..471d237af7 100644 --- a/controllers/actions.github.com/autoscalinglistener_controller.go +++ b/controllers/actions.github.com/autoscalinglistener_controller.go @@ -78,7 +78,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. if err := r.Get(ctx, req.NamespacedName, &autoscalingListener); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } - original := autoscalingListener.DeepCopy() + var original once[*v1alpha1.AutoscalingListener] if !autoscalingListener.DeletionTimestamp.IsZero() { if !controllerutil.ContainsFinalizer(&autoscalingListener, autoscalingListenerFinalizerName) { @@ -96,9 +96,14 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{Requeue: true, RequeueAfter: time.Second}, nil } - log.Info("Removing finalizer") - if controllerutil.RemoveFinalizer(&autoscalingListener, autoscalingListenerFinalizerName) { - if err := r.Patch(ctx, &autoscalingListener, client.MergeFrom(original)); err != nil && !kerrors.IsNotFound(err) { + removeFinalizer := controllerutil.ContainsFinalizer(&autoscalingListener, autoscalingListenerFinalizerName) + if removeFinalizer { + original.Do(autoscalingListener.DeepCopy) + controllerutil.RemoveFinalizer(&autoscalingListener, autoscalingListenerFinalizerName) + } + if removeFinalizer { + log.Info("Removing finalizer") + if err := r.Patch(ctx, &autoscalingListener, client.MergeFrom(original.Get())); err != nil && !kerrors.IsNotFound(err) { log.Error(err, "Failed to remove finalizer") return ctrl.Result{}, err } @@ -109,8 +114,13 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, nil } - if controllerutil.AddFinalizer(&autoscalingListener, autoscalingListenerFinalizerName) { - if err := r.Patch(ctx, &autoscalingListener, client.MergeFrom(original)); err != nil { + addFinalizer := !controllerutil.ContainsFinalizer(&autoscalingListener, autoscalingListenerFinalizerName) + if addFinalizer { + original.Do(autoscalingListener.DeepCopy) + controllerutil.AddFinalizer(&autoscalingListener, autoscalingListenerFinalizerName) + } + if addFinalizer { + if err := r.Patch(ctx, &autoscalingListener, client.MergeFrom(original.Get())); err != nil { log.Error(err, "Failed to add finalizer") return ctrl.Result{}, err } @@ -168,17 +178,19 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. labelsModified := !maps.Equal(serviceAccount.Labels, desiredLabels) desiredAnnotations := r.mergeAnnotations(serviceAccount.Annotations, desiredServiceAccount.Annotations) annotationsModified := !maps.Equal(serviceAccount.Annotations, desiredAnnotations) + var original once[*corev1.ServiceAccount] + if labelsModified { + original.Do(serviceAccount.DeepCopy) + serviceAccount.Labels = desiredLabels + } + if annotationsModified { + original.Do(serviceAccount.DeepCopy) + serviceAccount.Annotations = desiredAnnotations + } if labelsModified || annotationsModified { - updatedServiceAccount := serviceAccount.DeepCopy() - if labelsModified { - updatedServiceAccount.Labels = desiredLabels - } - if annotationsModified { - updatedServiceAccount.Annotations = desiredAnnotations - } log.Info("Updating listener service account") - if err := r.Patch(ctx, updatedServiceAccount, client.MergeFrom(&serviceAccount)); err != nil { + if err := r.Patch(ctx, &serviceAccount, client.MergeFrom(original.Get())); err != nil { log.Error(err, "Failed to update listener service account") return ctrl.Result{}, err } @@ -212,19 +224,22 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. desiredAnnotations := r.mergeAnnotations(listenerRole.Annotations, desiredRole.Annotations) annotationsModified := !maps.Equal(listenerRole.Annotations, desiredAnnotations) rulesModified := !reflect.DeepEqual(listenerRole.Rules, desiredRole.Rules) + var original once[*rbacv1.Role] + if labelsModified { + original.Do(listenerRole.DeepCopy) + listenerRole.Labels = desiredLabels + } + if annotationsModified { + original.Do(listenerRole.DeepCopy) + listenerRole.Annotations = desiredAnnotations + } + if rulesModified { + original.Do(listenerRole.DeepCopy) + listenerRole.Rules = desiredRole.Rules + } if labelsModified || annotationsModified || rulesModified { - updatedRole := listenerRole.DeepCopy() - if labelsModified { - updatedRole.Labels = desiredLabels - } - if annotationsModified { - updatedRole.Annotations = desiredAnnotations - } - if rulesModified { - updatedRole.Rules = desiredRole.Rules - } log.Info("Updating listener role") - if err := r.Patch(ctx, updatedRole, client.MergeFrom(&listenerRole)); err != nil { + if err := r.Patch(ctx, &listenerRole, client.MergeFrom(original.Get())); err != nil { log.Error(err, "Failed to update listener role") return ctrl.Result{}, err } @@ -253,16 +268,18 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. labelsModified := !maps.Equal(listenerRoleBinding.Labels, desiredLabels) desiredAnnotations := r.mergeAnnotations(listenerRoleBinding.Annotations, desiredRoleBinding.Annotations) annotationsModified := !maps.Equal(listenerRoleBinding.Annotations, desiredAnnotations) + var original once[*rbacv1.RoleBinding] + if labelsModified { + original.Do(listenerRoleBinding.DeepCopy) + listenerRoleBinding.Labels = desiredLabels + } + if annotationsModified { + original.Do(listenerRoleBinding.DeepCopy) + listenerRoleBinding.Annotations = desiredAnnotations + } if labelsModified || annotationsModified { - updatedRoleBinding := listenerRoleBinding.DeepCopy() - if labelsModified { - updatedRoleBinding.Labels = desiredLabels - } - if annotationsModified { - updatedRoleBinding.Annotations = desiredAnnotations - } log.Info("Updating listener role binding") - if err := r.Patch(ctx, updatedRoleBinding, client.MergeFrom(&listenerRoleBinding)); err != nil { + if err := r.Patch(ctx, &listenerRoleBinding, client.MergeFrom(original.Get())); err != nil { log.Error(err, "Failed to update listener role binding") return ctrl.Result{}, err } @@ -308,16 +325,18 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. labelsModified := !maps.Equal(proxySecret.Labels, desiredLabels) desiredAnnotations := r.mergeAnnotations(proxySecret.Annotations, desiredListenerProxy.Annotations) annotationsModified := !maps.Equal(proxySecret.Annotations, desiredAnnotations) + var original once[*corev1.Secret] + if labelsModified { + original.Do(proxySecret.DeepCopy) + proxySecret.Labels = desiredLabels + } + if annotationsModified { + original.Do(proxySecret.DeepCopy) + proxySecret.Annotations = desiredAnnotations + } if labelsModified || annotationsModified { - updatedProxySecret := proxySecret.DeepCopy() - if labelsModified { - updatedProxySecret.Labels = desiredLabels - } - if annotationsModified { - updatedProxySecret.Annotations = desiredAnnotations - } log.Info("Updating listener proxy secret") - if err := r.Patch(ctx, updatedProxySecret, client.MergeFrom(&proxySecret)); err != nil { + if err := r.Patch(ctx, &proxySecret, client.MergeFrom(original.Get())); err != nil { log.Error(err, "Failed to update listener proxy secret") return ctrl.Result{}, err } @@ -394,17 +413,19 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. labelsModified := !maps.Equal(listenerConfigSecret.Labels, desiredLabels) desiredAnnotations := r.mergeAnnotations(listenerConfigSecret.Annotations, desiredSecret.Annotations) annotationsModified := !maps.Equal(listenerConfigSecret.Annotations, desiredAnnotations) + var original once[*corev1.Secret] + if labelsModified { + original.Do(listenerConfigSecret.DeepCopy) + listenerConfigSecret.Labels = desiredLabels + } + if annotationsModified { + original.Do(listenerConfigSecret.DeepCopy) + listenerConfigSecret.Annotations = desiredAnnotations + } if labelsModified || annotationsModified { - updatedSecret := listenerConfigSecret.DeepCopy() - if labelsModified { - updatedSecret.Labels = desiredLabels - } - if annotationsModified { - updatedSecret.Annotations = desiredAnnotations - } - log.Info("Updating listener config secret", "namespace", updatedSecret.Namespace, "name", updatedSecret.Name) - if err := r.Patch(ctx, updatedSecret, client.MergeFrom(&listenerConfigSecret)); err != nil { + log.Info("Updating listener config secret", "namespace", listenerConfigSecret.Namespace, "name", listenerConfigSecret.Name) + if err := r.Patch(ctx, &listenerConfigSecret, client.MergeFrom(original.Get())); err != nil { return ctrl.Result{}, fmt.Errorf("failed to update listener config secret: %w", err) } return ctrl.Result{Requeue: true}, nil @@ -463,6 +484,20 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, err } + desiredLabels := r.filterAndMergeLabels(listenerPod.Labels, desiredPod.Labels) + labelsModified := !maps.Equal(listenerPod.Labels, desiredLabels) + desiredAnnotations := r.mergeAnnotations(listenerPod.Annotations, desiredPod.Annotations) + annotationsModified := !maps.Equal(listenerPod.Annotations, desiredAnnotations) + var original once[*corev1.Pod] + if labelsModified { + original.Do(listenerPod.DeepCopy) + listenerPod.Labels = desiredLabels + } + if annotationsModified { + original.Do(listenerPod.DeepCopy) + listenerPod.Annotations = desiredAnnotations + } + shouldReCreate := listenerPodSpecRequiresRecreation(&listenerPod, desiredPod) if shouldReCreate { log.Info("Listener pod dependency changed, recreating listener pod") @@ -474,22 +509,10 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, nil } - desiredLabels := r.filterAndMergeLabels(listenerPod.Labels, desiredPod.Labels) - labelsModified := !maps.Equal(listenerPod.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(listenerPod.Annotations, desiredPod.Annotations) - annotationsModified := !maps.Equal(listenerPod.Annotations, desiredAnnotations) - if labelsModified || annotationsModified { - updatedPod := listenerPod.DeepCopy() - if labelsModified { - updatedPod.Labels = desiredLabels - } - if annotationsModified { - updatedPod.Annotations = desiredAnnotations - } - log.Info("Updating listener pod", "namespace", updatedPod.Namespace, "name", updatedPod.Name) - if err := r.Patch(ctx, updatedPod, client.MergeFrom(&listenerPod)); err != nil { - log.Error(err, "Unable to update listener pod", "namespace", updatedPod.Namespace, "name", updatedPod.Name) + log.Info("Updating listener pod", "namespace", listenerPod.Namespace, "name", listenerPod.Name) + if err := r.Patch(ctx, &listenerPod, client.MergeFrom(original.Get())); err != nil { + log.Error(err, "Unable to update listener pod", "namespace", listenerPod.Namespace, "name", listenerPod.Name) return ctrl.Result{}, err } return ctrl.Result{}, nil diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller.go b/controllers/actions.github.com/autoscalingrunnerset_controller.go index a632c3f0bc..5b98e2c562 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller.go @@ -74,7 +74,7 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl if err := r.Get(ctx, req.NamespacedName, &autoscalingRunnerSet); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } - original := autoscalingRunnerSet.DeepCopy() + var original once[*v1alpha1.AutoscalingRunnerSet] if !autoscalingRunnerSet.DeletionTimestamp.IsZero() { if !controllerutil.ContainsFinalizer(&autoscalingRunnerSet, autoscalingRunnerSetFinalizerName) { @@ -90,7 +90,7 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl if !done { log.Info("Waiting for resources to be cleaned up before removing finalizer") return ctrl.Result{ - RequeueAfter: 5 * time.Second, + RequeueAfter: 2 * time.Second, }, nil } @@ -99,9 +99,14 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl return ctrl.Result{}, err } - if controllerutil.RemoveFinalizer(&autoscalingRunnerSet, autoscalingRunnerSetFinalizerName) { + removeFinalizer := controllerutil.ContainsFinalizer(&autoscalingRunnerSet, autoscalingRunnerSetFinalizerName) + if removeFinalizer { + original.Do(autoscalingRunnerSet.DeepCopy) + controllerutil.RemoveFinalizer(&autoscalingRunnerSet, autoscalingRunnerSetFinalizerName) + } + if removeFinalizer { log.Info("Removing finalizer") - if err := r.Patch(ctx, &autoscalingRunnerSet, client.MergeFrom(original)); err != nil && !kerrors.IsNotFound(err) { + if err := r.Patch(ctx, &autoscalingRunnerSet, client.MergeFrom(original.Get())); err != nil && !kerrors.IsNotFound(err) { log.Error(err, "Failed to update autoscaling runner set without finalizer") return ctrl.Result{}, err } @@ -130,10 +135,15 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl return ctrl.Result{}, nil } - if controllerutil.AddFinalizer(&autoscalingRunnerSet, autoscalingRunnerSetFinalizerName) { + addFinalizer := !controllerutil.ContainsFinalizer(&autoscalingRunnerSet, autoscalingRunnerSetFinalizerName) + if addFinalizer { + original.Do(autoscalingRunnerSet.DeepCopy) + controllerutil.AddFinalizer(&autoscalingRunnerSet, autoscalingRunnerSetFinalizerName) + } + if addFinalizer { log.Info("Adding finalizer") - if err := r.Patch(ctx, &autoscalingRunnerSet, client.MergeFrom(original)); err != nil { + if err := r.Patch(ctx, &autoscalingRunnerSet, client.MergeFrom(original.Get())); err != nil { log.Error(err, "Failed to update autoscaling runner set with finalizer") return ctrl.Result{}, err } @@ -195,12 +205,22 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl return ctrl.Result{}, nil } - original := ephemeralRunnerSet.DeepCopy() - ephemeralRunnerSet.Spec.Replicas = 0 - ephemeralRunnerSet.Spec.PatchID = 0 - if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original)); err != nil { - log.Error(err, "Failed to patch ephemeral runner set with 0 replicas and reset patch ID for the outdated runner set") - return ctrl.Result{}, err + var original once[*v1alpha1.EphemeralRunnerSet] + ephemeralRunnerReplicasModified := ephemeralRunnerSet.Spec.Replicas != 0 + if ephemeralRunnerReplicasModified { + original.Do(ephemeralRunnerSet.DeepCopy) + ephemeralRunnerSet.Spec.Replicas = 0 + } + ephemeralRunnerPatchIDModified := ephemeralRunnerSet.Spec.PatchID != 0 + if ephemeralRunnerPatchIDModified { + original.Do(ephemeralRunnerSet.DeepCopy) + ephemeralRunnerSet.Spec.PatchID = 0 + } + if ephemeralRunnerReplicasModified || ephemeralRunnerPatchIDModified { + if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original.Get())); err != nil { + log.Error(err, "Failed to patch ephemeral runner set with 0 replicas and reset patch ID for the outdated runner set") + return ctrl.Result{}, err + } } return ctrl.Result{}, nil @@ -282,40 +302,44 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl return ctrl.Result{}, nil } - if ephemeralRunnerSetActionableSpecChanged(&ephemeralRunnerSet, desired) { - original := ephemeralRunnerSet.DeepCopy() - ephemeralRunnerSet.Spec.EphemeralRunnerMetadata = desired.Spec.EphemeralRunnerMetadata + var original once[*v1alpha1.EphemeralRunnerSet] + + ephemeralRunnerActionableSpecModified := !cmp.Equal(ephemeralRunnerSet.Spec.EphemeralRunnerSpec, desired.Spec.EphemeralRunnerSpec) + if ephemeralRunnerActionableSpecModified { + original.Do(ephemeralRunnerSet.DeepCopy) ephemeralRunnerSet.Spec.EphemeralRunnerSpec = desired.Spec.EphemeralRunnerSpec ephemeralRunnerSet.Spec.ActionableRevision = nextActionableRevision(&ephemeralRunnerSet) - ephemeralRunnerSet.Labels = r.filterAndMergeLabels(ephemeralRunnerSet.Labels, desired.Labels) - ephemeralRunnerSet.Annotations = desired.Annotations - - log.Info("Updating ephemeral runner set spec to match the desired spec") - if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original)); err != nil { - log.Error(err, "Failed to patch ephemeral runner set to match the desired spec") - return ctrl.Result{}, err - } - - log.Info("Successfully patched ephemeral runner set spec") - return ctrl.Result{}, nil } ephemeralRunnerMetadataModified := !cmp.Equal(ephemeralRunnerSet.Spec.EphemeralRunnerMetadata, desired.Spec.EphemeralRunnerMetadata) + if ephemeralRunnerMetadataModified { + original.Do(ephemeralRunnerSet.DeepCopy) + ephemeralRunnerSet.Spec.EphemeralRunnerMetadata = desired.Spec.EphemeralRunnerMetadata + } ephemeralRunnerLabelsModified := !maps.Equal(ephemeralRunnerSet.Labels, desired.Labels) + if ephemeralRunnerLabelsModified { + original.Do(ephemeralRunnerSet.DeepCopy) + ephemeralRunnerSet.Labels = r.filterAndMergeLabels(ephemeralRunnerSet.Labels, desired.Labels) + } ephemeralRunnerAnnotationsModified := !maps.Equal(ephemeralRunnerSet.Annotations, desired.Annotations) + if ephemeralRunnerAnnotationsModified { + original.Do(ephemeralRunnerSet.DeepCopy) + ephemeralRunnerSet.Annotations = r.mergeAnnotations(ephemeralRunnerSet.Annotations, desired.Annotations) + } - if ephemeralRunnerLabelsModified || ephemeralRunnerAnnotationsModified || ephemeralRunnerMetadataModified { - original := ephemeralRunnerSet.DeepCopy() - ephemeralRunnerSet.Labels = r.filterAndMergeLabels(ephemeralRunnerSet.Labels, desired.Labels) - ephemeralRunnerSet.Annotations = desired.Annotations - ephemeralRunnerSet.Spec.EphemeralRunnerMetadata = desired.Spec.EphemeralRunnerMetadata - log.Info("Updating ephemeral runner set metadata to match desired labels and annotations") + if ephemeralRunnerActionableSpecModified || ephemeralRunnerLabelsModified || ephemeralRunnerAnnotationsModified || ephemeralRunnerMetadataModified { + original := original.Get() + if ephemeralRunnerActionableSpecModified { + log.Info("Updating ephemeral runner set spec to match the desired spec") + } else { + log.Info("Updating ephemeral runner set metadata to match desired labels and annotations") + } if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original)); err != nil { - log.Error(err, "Failed to patch ephemeral runner set metadata to match desired labels and annotations") + log.Error(err, "Failed to patch ephemeral runner set to match the desired state") return ctrl.Result{}, err } - log.Info("Successfully patched ephemeral runner set metadata") + log.Info("Successfully patched ephemeral runner set") return ctrl.Result{}, nil } } @@ -512,10 +536,11 @@ func (r *AutoscalingRunnerSetReconciler) removeFinalizersFromDependentResources( } func (r *AutoscalingRunnerSetReconciler) createRunnerScaleSet(ctx context.Context, autoscalingRunnerSet *v1alpha1.AutoscalingRunnerSet, logger logr.Logger) (ctrl.Result, error) { - original := autoscalingRunnerSet.DeepCopy() + var original once[*v1alpha1.AutoscalingRunnerSet] logger.Info("Creating a new runner scale set") actionsClient, err := r.GetActionsService(ctx, autoscalingRunnerSet) if len(autoscalingRunnerSet.Spec.RunnerScaleSetName) == 0 { + original.Do(autoscalingRunnerSet.DeepCopy) autoscalingRunnerSet.Spec.RunnerScaleSetName = autoscalingRunnerSet.Name } if err != nil { @@ -590,6 +615,7 @@ func (r *AutoscalingRunnerSetReconciler) createRunnerScaleSet(ctx context.Contex actionsClient.SetSystemInfo(info) logger.Info("Created/Reused a runner scale set", "id", runnerScaleSet.ID, "runnerGroupName", runnerScaleSet.RunnerGroupName) + original.Do(autoscalingRunnerSet.DeepCopy) if autoscalingRunnerSet.Annotations == nil { autoscalingRunnerSet.Annotations = map[string]string{} } @@ -606,7 +632,7 @@ func (r *AutoscalingRunnerSetReconciler) createRunnerScaleSet(ctx context.Contex } logger.Info("Adding runner scale set ID, name and runner group name as an annotation and url labels") - if err = r.Patch(ctx, autoscalingRunnerSet, client.MergeFrom(original)); err != nil { + if err = r.Patch(ctx, autoscalingRunnerSet, client.MergeFrom(original.Get())); err != nil { logger.Error(err, "Failed to add runner scale set ID, name and runner group name as an annotation") return ctrl.Result{}, err } @@ -1093,6 +1119,11 @@ func (c *autoscalingRunnerSetFinalizerDependencyCleaner) removeManagerRoleBindin err := c.client.Get(ctx, types.NamespacedName{Name: managerRoleBindingName, Namespace: c.autoscalingRunnerSet.Namespace}, roleBinding) switch { case err == nil: + if !controllerutil.ContainsFinalizer(roleBinding, AutoscalingRunnerSetCleanupFinalizerName) { + c.logger.Info("Manager role binding finalizer has already been removed", "name", managerRoleBindingName) + return + } + original := roleBinding.DeepCopy() if controllerutil.RemoveFinalizer(roleBinding, AutoscalingRunnerSetCleanupFinalizerName) { if err = c.client.Patch(ctx, roleBinding, client.MergeFrom(original)); err != nil { @@ -1133,6 +1164,11 @@ func (c *autoscalingRunnerSetFinalizerDependencyCleaner) removeManagerRoleFinali err := c.client.Get(ctx, types.NamespacedName{Name: managerRoleName, Namespace: c.autoscalingRunnerSet.Namespace}, role) switch { case err == nil: + if !controllerutil.ContainsFinalizer(role, AutoscalingRunnerSetCleanupFinalizerName) { + c.logger.Info("Manager role finalizer has already been removed", "name", managerRoleName) + return + } + original := role.DeepCopy() if controllerutil.RemoveFinalizer(role, AutoscalingRunnerSetCleanupFinalizerName) { if err := c.client.Patch(ctx, role, client.MergeFrom(original)); err != nil { diff --git a/controllers/actions.github.com/ephemeralrunner_controller.go b/controllers/actions.github.com/ephemeralrunner_controller.go index 256ed311c4..07dfe7de81 100644 --- a/controllers/actions.github.com/ephemeralrunner_controller.go +++ b/controllers/actions.github.com/ephemeralrunner_controller.go @@ -94,7 +94,7 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ if err := r.Get(ctx, req.NamespacedName, &ephemeralRunner); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } - original := ephemeralRunner.DeepCopy() + var original once[*v1alpha1.EphemeralRunner] if !ephemeralRunner.DeletionTimestamp.IsZero() { r.publishEphemeralRunnerPhaseMetric(&ephemeralRunner, "", log) @@ -103,7 +103,8 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ return ctrl.Result{}, nil } - if controllerutil.ContainsFinalizer(&ephemeralRunner, ephemeralRunnerActionsFinalizerName) { + removeActionsFinalizer := controllerutil.ContainsFinalizer(&ephemeralRunner, ephemeralRunnerActionsFinalizerName) + if removeActionsFinalizer { log.Info("Trying to clean up runner from the service") ok, err := r.cleanupRunnerFromService(ctx, &ephemeralRunner, log) if err != nil { @@ -116,14 +117,16 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ } log.Info("Runner is cleaned up from the service, removing finalizer") - if controllerutil.RemoveFinalizer(&ephemeralRunner, ephemeralRunnerActionsFinalizerName) { - log.Info("Removed finalizer from ephemeral runner") - if err := r.Patch(ctx, &ephemeralRunner, client.MergeFrom(original)); err != nil { - log.Error(err, "Failed to update ephemeral runner after removing finalizer") - return ctrl.Result{}, err - } - } + original.Do(ephemeralRunner.DeepCopy) + controllerutil.RemoveFinalizer(&ephemeralRunner, ephemeralRunnerActionsFinalizerName) + } + if removeActionsFinalizer { log.Info("Removed finalizer from ephemeral runner") + if err := r.Patch(ctx, &ephemeralRunner, client.MergeFrom(original.Get())); err != nil { + log.Error(err, "Failed to update ephemeral runner after removing finalizer") + return ctrl.Result{}, err + } + return ctrl.Result{}, nil } log.Info("Finalizing ephemeral runner") @@ -142,10 +145,15 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ } } - log.Info("Removing finalizer") - if controllerutil.RemoveFinalizer(&ephemeralRunner, ephemeralRunnerFinalizerName) { + removeFinalizer := controllerutil.ContainsFinalizer(&ephemeralRunner, ephemeralRunnerFinalizerName) + if removeFinalizer { + original.Do(ephemeralRunner.DeepCopy) + controllerutil.RemoveFinalizer(&ephemeralRunner, ephemeralRunnerFinalizerName) + } + if removeFinalizer { + log.Info("Removing finalizer") log.Info("Removed finalizer from ephemeral runner") - if err := r.Patch(ctx, &ephemeralRunner, client.MergeFrom(original)); client.IgnoreNotFound(err) != nil { + if err := r.Patch(ctx, &ephemeralRunner, client.MergeFrom(original.Get())); client.IgnoreNotFound(err) != nil { log.Error(err, "Failed to update ephemeral runner after removing finalizer") return ctrl.Result{}, err } @@ -171,17 +179,21 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ return ctrl.Result{}, nil } - addFinalizers := !controllerutil.ContainsFinalizer(&ephemeralRunner, ephemeralRunnerFinalizerName) || !controllerutil.ContainsFinalizer(&ephemeralRunner, ephemeralRunnerActionsFinalizerName) - if addFinalizers { + ephemeralRunnerFinalizerModified := !controllerutil.ContainsFinalizer(&ephemeralRunner, ephemeralRunnerFinalizerName) + if ephemeralRunnerFinalizerModified { + original.Do(ephemeralRunner.DeepCopy) + controllerutil.AddFinalizer(&ephemeralRunner, ephemeralRunnerFinalizerName) + } + ephemeralRunnerActionsFinalizerModified := !controllerutil.ContainsFinalizer(&ephemeralRunner, ephemeralRunnerActionsFinalizerName) + if ephemeralRunnerActionsFinalizerModified { + original.Do(ephemeralRunner.DeepCopy) + controllerutil.AddFinalizer(&ephemeralRunner, ephemeralRunnerActionsFinalizerName) + } + if ephemeralRunnerFinalizerModified || ephemeralRunnerActionsFinalizerModified { log.Info("Adding finalizers") - var addedFinalizers bool - addedFinalizers = addedFinalizers || controllerutil.AddFinalizer(&ephemeralRunner, ephemeralRunnerFinalizerName) - addedFinalizers = addedFinalizers || controllerutil.AddFinalizer(&ephemeralRunner, ephemeralRunnerActionsFinalizerName) - if addedFinalizers { - if err := r.Patch(ctx, &ephemeralRunner, client.MergeFrom(original)); err != nil { - log.Error(err, "Failed to update with finalizer set") - return ctrl.Result{}, err - } + if err := r.Patch(ctx, &ephemeralRunner, client.MergeFrom(original.Get())); err != nil { + log.Error(err, "Failed to update with finalizer set") + return ctrl.Result{}, err } log.Info("Successfully added finalizers") } diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller.go b/controllers/actions.github.com/ephemeralrunnerset_controller.go index d979813732..0748f6ca73 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller.go @@ -80,7 +80,7 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R if err := r.Get(ctx, req.NamespacedName, &ephemeralRunnerSet); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } - original := ephemeralRunnerSet.DeepCopy() + var original once[*v1alpha1.EphemeralRunnerSet] // Requested deletion does not need reconciled. if !ephemeralRunnerSet.DeletionTimestamp.IsZero() { @@ -109,9 +109,14 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R return ctrl.Result{RequeueAfter: 1 * time.Second}, nil } - log.Info("Removing finalizer") - if controllerutil.RemoveFinalizer(&ephemeralRunnerSet, EphemeralRunnerSetFinalizerName) { - if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original)); err != nil { + removeFinalizer := controllerutil.ContainsFinalizer(&ephemeralRunnerSet, EphemeralRunnerSetFinalizerName) + if removeFinalizer { + original.Do(ephemeralRunnerSet.DeepCopy) + controllerutil.RemoveFinalizer(&ephemeralRunnerSet, EphemeralRunnerSetFinalizerName) + } + if removeFinalizer { + log.Info("Removing finalizer") + if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original.Get())); err != nil { log.Error(err, "Failed to update ephemeral runner set with removed finalizer") return ctrl.Result{}, err } @@ -123,9 +128,14 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R } // Add finalizer if not present - if controllerutil.AddFinalizer(&ephemeralRunnerSet, EphemeralRunnerSetFinalizerName) { + addFinalizer := !controllerutil.ContainsFinalizer(&ephemeralRunnerSet, EphemeralRunnerSetFinalizerName) + if addFinalizer { + original.Do(ephemeralRunnerSet.DeepCopy) + controllerutil.AddFinalizer(&ephemeralRunnerSet, EphemeralRunnerSetFinalizerName) + } + if addFinalizer { log.Info("Adding finalizer") - if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original)); err != nil { + if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original.Get())); err != nil { log.Error(err, "Failed to update ephemeral runner set with new finalizer") return ctrl.Result{}, err } @@ -264,8 +274,8 @@ func (r *EphemeralRunnerSetReconciler) patchAppliedActionableRevisionStatus(ctx return err } - original := latest.DeepCopy() - latest.Status.AppliedActionableRevision = targetAppliedRevision + desiredStatus := latest.Status + desiredStatus.AppliedActionableRevision = targetAppliedRevision ephemeralRunnerList := new(v1alpha1.EphemeralRunnerList) if err := r.List(ctx, ephemeralRunnerList, client.InNamespace(latest.Namespace), client.MatchingFields{resourceOwnerKey: latest.Name}); err != nil { @@ -273,13 +283,15 @@ func (r *EphemeralRunnerSetReconciler) patchAppliedActionableRevisionStatus(ctx } if len(newEphemeralRunnersByStates(ephemeralRunnerList).outdated) == 0 { - latest.Status.Phase = v1alpha1.EphemeralRunnerSetPhaseRunning + desiredStatus.Phase = v1alpha1.EphemeralRunnerSetPhaseRunning } - if original.Status == latest.Status { + if latest.Status == desiredStatus { return nil } + original := latest.DeepCopy() + latest.Status = desiredStatus return r.Status().Patch(ctx, &latest, client.MergeFrom(original)) }) } @@ -303,7 +315,6 @@ func (r *EphemeralRunnerSetReconciler) patchFinishedRunnerCleanupPatchIDStatus(c } func (r *EphemeralRunnerSetReconciler) updateStatus(ctx context.Context, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, state *ephemeralRunnersByState, log logr.Logger) error { - original := ephemeralRunnerSet.DeepCopy() var phase v1alpha1.EphemeralRunnerSetPhase switch { case len(state.outdated) > 0: @@ -321,6 +332,7 @@ func (r *EphemeralRunnerSetReconciler) updateStatus(ctx context.Context, ephemer // Update the status if needed. if ephemeralRunnerSet.Status != desiredStatus { + original := ephemeralRunnerSet.DeepCopy() ephemeralRunnerSet.Status = desiredStatus if err := r.Status().Patch(ctx, ephemeralRunnerSet, client.MergeFrom(original)); err != nil { log.Error(err, "Failed to update EphemeralRunnerSet status") @@ -530,22 +542,25 @@ func (r *EphemeralRunnerSetReconciler) reconcileEphemeralRunnerSetProxySecret(ct labelsModified := !maps.Equal(proxySecret.Labels, desiredLabels) desiredAnnotations := r.mergeAnnotations(proxySecret.Annotations, desiredRunnerSetProxy.Annotations) annotationsModified := !maps.Equal(proxySecret.Annotations, desiredAnnotations) + var original once[*corev1.Secret] + if dataModified { + original.Do(proxySecret.DeepCopy) + proxySecret.Data = desiredRunnerSetProxy.Data + } + if labelsModified { + original.Do(proxySecret.DeepCopy) + proxySecret.Labels = desiredLabels + } + if annotationsModified { + original.Do(proxySecret.DeepCopy) + proxySecret.Annotations = desiredAnnotations + } if dataModified || labelsModified || annotationsModified { - updatedProxySecret := proxySecret.DeepCopy() - if dataModified { - updatedProxySecret.Data = desiredRunnerSetProxy.Data - } - if labelsModified { - updatedProxySecret.Labels = desiredLabels - } - if annotationsModified { - updatedProxySecret.Annotations = desiredAnnotations - } log.Info("Updating ephemeralRunnerSet proxy secret") - if err := r.Patch(ctx, updatedProxySecret, client.MergeFrom(&proxySecret)); err != nil { + if err := r.Patch(ctx, &proxySecret, client.MergeFrom(original.Get())); err != nil { return nil, false, fmt.Errorf("failed to update ephemeralRunnerSet proxy secret: %w", err) } - return updatedProxySecret, true, nil + return &proxySecret, true, nil } return &proxySecret, false, nil case kerrors.IsNotFound(err): diff --git a/controllers/actions.github.com/resourcebuilder.go b/controllers/actions.github.com/resourcebuilder.go index c660a94f4f..3657cfaf84 100644 --- a/controllers/actions.github.com/resourcebuilder.go +++ b/controllers/actions.github.com/resourcebuilder.go @@ -1084,7 +1084,6 @@ func (b *ResourceBuilder) mergeAnnotations(base, overwrite map[string]string) ma if base == nil && overwrite == nil { return nil } - base = maps.Clone(base) maps.Copy(base, overwrite) return base } diff --git a/controllers/actions.github.com/utils.go b/controllers/actions.github.com/utils.go index a77b24ba17..c17aba6d7d 100644 --- a/controllers/actions.github.com/utils.go +++ b/controllers/actions.github.com/utils.go @@ -1,8 +1,6 @@ package actionsgithubcom -import ( - "k8s.io/apimachinery/pkg/util/rand" -) +import "sigs.k8s.io/controller-runtime/pkg/client" func FilterLabels(labels map[string]string, filter string) map[string]string { filtered := map[string]string{} @@ -16,12 +14,23 @@ func FilterLabels(labels map[string]string, filter string) map[string]string { return filtered } -var letterRunes = []rune("abcdefghijklmnopqrstuvwxyz1234567890") +type once[T client.Object] struct { + value T + fn func(T) *T + done bool +} + +func (o *once[T]) Do(f func() T) T { + if !o.done { + o.value = f() + o.done = true + } + return o.value +} -func RandStringRunes(n int) string { - b := make([]rune, n) - for i := range b { - b[i] = letterRunes[rand.Intn(len(letterRunes))] +func (o *once[T]) Get() T { + if !o.done { + panic("not done") } - return string(b) + return o.value } diff --git a/controllers/actions.github.com/utils_test.go b/controllers/actions.github.com/utils_test.go index 9e98b981bd..d0c35d1692 100644 --- a/controllers/actions.github.com/utils_test.go +++ b/controllers/actions.github.com/utils_test.go @@ -3,6 +3,8 @@ package actionsgithubcom import ( "reflect" "testing" + + "k8s.io/apimachinery/pkg/util/rand" ) func Test_filterLabels(t *testing.T) { @@ -32,3 +34,13 @@ func Test_filterLabels(t *testing.T) { }) } } + +var letterRunes = []rune("abcdefghijklmnopqrstuvwxyz1234567890") + +func RandStringRunes(n int) string { + b := make([]rune, n) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return string(b) +} From ab1c70d8e3432364bad6c210fa5a8bcbfeaf6e1d Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Mon, 20 Jul 2026 13:56:40 +0200 Subject: [PATCH 30/31] Use lazy copy for annotations --- .../autoscalinglistener_controller.go | 183 +++++++++++------- .../autoscalingrunnerset_controller.go | 64 +++--- .../ephemeralrunner_controller.go | 10 +- .../ephemeralrunnerset_controller.go | 20 +- .../ephemeralrunnerset_controller_test.go | 25 +++ .../actions.github.com/resourcebuilder.go | 137 ++++++++----- .../resourcebuilder_test.go | 71 +++++++ controllers/actions.github.com/utils.go | 18 +- 8 files changed, 361 insertions(+), 167 deletions(-) diff --git a/controllers/actions.github.com/autoscalinglistener_controller.go b/controllers/actions.github.com/autoscalinglistener_controller.go index 471d237af7..3dc1c63c7b 100644 --- a/controllers/actions.github.com/autoscalinglistener_controller.go +++ b/controllers/actions.github.com/autoscalinglistener_controller.go @@ -19,11 +19,10 @@ package actionsgithubcom import ( "context" "fmt" - "maps" - "reflect" "time" "github.com/go-logr/logr" + "github.com/google/go-cmp/cmp" kerrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -78,7 +77,6 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. if err := r.Get(ctx, req.NamespacedName, &autoscalingListener); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } - var original once[*v1alpha1.AutoscalingListener] if !autoscalingListener.DeletionTimestamp.IsZero() { if !controllerutil.ContainsFinalizer(&autoscalingListener, autoscalingListenerFinalizerName) { @@ -93,15 +91,15 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. } if requeue { log.Info("Waiting for resources to be deleted before removing finalizer") - return ctrl.Result{Requeue: true, RequeueAfter: time.Second}, nil + return ctrl.Result{RequeueAfter: time.Second}, nil } - removeFinalizer := controllerutil.ContainsFinalizer(&autoscalingListener, autoscalingListenerFinalizerName) - if removeFinalizer { - original.Do(autoscalingListener.DeepCopy) + original := newOnce(autoscalingListener.DeepCopy) + if controllerutil.ContainsFinalizer(&autoscalingListener, autoscalingListenerFinalizerName) { + original.Do() controllerutil.RemoveFinalizer(&autoscalingListener, autoscalingListenerFinalizerName) } - if removeFinalizer { + if original.Called() { log.Info("Removing finalizer") if err := r.Patch(ctx, &autoscalingListener, client.MergeFrom(original.Get())); err != nil && !kerrors.IsNotFound(err) { log.Error(err, "Failed to remove finalizer") @@ -114,19 +112,19 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, nil } + original := newOnce(autoscalingListener.DeepCopy) addFinalizer := !controllerutil.ContainsFinalizer(&autoscalingListener, autoscalingListenerFinalizerName) if addFinalizer { - original.Do(autoscalingListener.DeepCopy) + original.Do() controllerutil.AddFinalizer(&autoscalingListener, autoscalingListenerFinalizerName) } - if addFinalizer { + if original.Called() { if err := r.Patch(ctx, &autoscalingListener, client.MergeFrom(original.Get())); err != nil { log.Error(err, "Failed to add finalizer") return ctrl.Result{}, err } log.Info("Successfully added finalizer") - return ctrl.Result{}, nil } // Check if the AutoscalingRunnerSet exists @@ -174,28 +172,40 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, err } - desiredLabels := r.filterAndMergeLabels(serviceAccount.Labels, desiredServiceAccount.Labels) - labelsModified := !maps.Equal(serviceAccount.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(serviceAccount.Annotations, desiredServiceAccount.Annotations) - annotationsModified := !maps.Equal(serviceAccount.Annotations, desiredAnnotations) - var original once[*corev1.ServiceAccount] + desiredLabels, labelsModified := r.mergeLabels(serviceAccount.Labels, desiredServiceAccount.Labels) + original := newOnce(serviceAccount.DeepCopy) if labelsModified { - original.Do(serviceAccount.DeepCopy) + original.Do() serviceAccount.Labels = desiredLabels } + desiredAnnotations, annotationsModified := r.mergeAnnotations(serviceAccount.Annotations, desiredServiceAccount.Annotations) if annotationsModified { - original.Do(serviceAccount.DeepCopy) + original.Do() serviceAccount.Annotations = desiredAnnotations } - if labelsModified || annotationsModified { + secretsModified := !cmp.Equal(serviceAccount.Secrets, desiredServiceAccount.Secrets) + if secretsModified { + original.Do() + serviceAccount.Secrets = desiredServiceAccount.Secrets + } + imagePullSecretsModified := !cmp.Equal(serviceAccount.ImagePullSecrets, desiredServiceAccount.ImagePullSecrets) + if imagePullSecretsModified { + original.Do() + serviceAccount.ImagePullSecrets = desiredServiceAccount.ImagePullSecrets + } + automountServiceAccountTokenModified := !cmp.Equal(serviceAccount.AutomountServiceAccountToken, desiredServiceAccount.AutomountServiceAccountToken) + if automountServiceAccountTokenModified { + original.Do() + serviceAccount.AutomountServiceAccountToken = desiredServiceAccount.AutomountServiceAccountToken + } + + if original.Called() { log.Info("Updating listener service account") if err := r.Patch(ctx, &serviceAccount, client.MergeFrom(original.Get())); err != nil { log.Error(err, "Failed to update listener service account") return ctrl.Result{}, err } - - return ctrl.Result{Requeue: true}, nil } case kerrors.IsNotFound(err): // Create a service account for the listener pod in the controller namespace @@ -218,32 +228,29 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. ) switch { case err == nil: + original := newOnce(listenerRole.DeepCopy) desiredRole := r.newScaleSetListenerRole(&autoscalingListener) - desiredLabels := r.filterAndMergeLabels(listenerRole.Labels, desiredRole.Labels) - labelsModified := !maps.Equal(listenerRole.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(listenerRole.Annotations, desiredRole.Annotations) - annotationsModified := !maps.Equal(listenerRole.Annotations, desiredAnnotations) - rulesModified := !reflect.DeepEqual(listenerRole.Rules, desiredRole.Rules) - var original once[*rbacv1.Role] + desiredLabels, labelsModified := r.mergeLabels(listenerRole.Labels, desiredRole.Labels) if labelsModified { - original.Do(listenerRole.DeepCopy) + original.Do() listenerRole.Labels = desiredLabels } + desiredAnnotations, annotationsModified := r.mergeAnnotations(listenerRole.Annotations, desiredRole.Annotations) if annotationsModified { - original.Do(listenerRole.DeepCopy) + original.Do() listenerRole.Annotations = desiredAnnotations } + rulesModified := !cmp.Equal(listenerRole.Rules, desiredRole.Rules) if rulesModified { - original.Do(listenerRole.DeepCopy) + original.Do() listenerRole.Rules = desiredRole.Rules } - if labelsModified || annotationsModified || rulesModified { + if original.Called() { log.Info("Updating listener role") if err := r.Patch(ctx, &listenerRole, client.MergeFrom(original.Get())); err != nil { log.Error(err, "Failed to update listener role") return ctrl.Result{}, err } - return ctrl.Result{Requeue: true}, nil } case kerrors.IsNotFound(err): // Create a role for the listener pod in the AutoScalingRunnerSet namespace @@ -259,35 +266,42 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. err = r.Get(ctx, types.NamespacedName{Namespace: autoscalingListener.Spec.AutoscalingRunnerSetNamespace, Name: autoscalingListener.Name}, &listenerRoleBinding) switch { case err == nil: + original := newOnce(listenerRoleBinding.DeepCopy) desiredRoleBinding := r.newScaleSetListenerRoleBinding( &autoscalingListener, &listenerRole, &serviceAccount, ) - desiredLabels := r.filterAndMergeLabels(listenerRoleBinding.Labels, desiredRoleBinding.Labels) - labelsModified := !maps.Equal(listenerRoleBinding.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(listenerRoleBinding.Annotations, desiredRoleBinding.Annotations) - annotationsModified := !maps.Equal(listenerRoleBinding.Annotations, desiredAnnotations) - var original once[*rbacv1.RoleBinding] + desiredLabels, labelsModified := r.mergeLabels(listenerRoleBinding.Labels, desiredRoleBinding.Labels) if labelsModified { - original.Do(listenerRoleBinding.DeepCopy) + original.Do() listenerRoleBinding.Labels = desiredLabels } + + desiredAnnotations, annotationsModified := r.mergeAnnotations(listenerRoleBinding.Annotations, desiredRoleBinding.Annotations) if annotationsModified { - original.Do(listenerRoleBinding.DeepCopy) + original.Do() listenerRoleBinding.Annotations = desiredAnnotations } - if labelsModified || annotationsModified { + rulesModified := !cmp.Equal(listenerRoleBinding.RoleRef, desiredRoleBinding.RoleRef) + if rulesModified { + original.Do() + listenerRoleBinding.RoleRef = desiredRoleBinding.RoleRef + } + + subjectsModified := !cmp.Equal(listenerRoleBinding.Subjects, desiredRoleBinding.Subjects) + if subjectsModified { + original.Do() + listenerRoleBinding.Subjects = desiredRoleBinding.Subjects + } + + if original.Called() { log.Info("Updating listener role binding") if err := r.Patch(ctx, &listenerRoleBinding, client.MergeFrom(original.Get())); err != nil { log.Error(err, "Failed to update listener role binding") return ctrl.Result{}, err } - - log.Info("Updated listener role binding") - return ctrl.Result{Requeue: true}, nil } - case kerrors.IsNotFound(err): // Create a role binding for the listener pod in the AutoScalingRunnerSet namespace log.Info("Creating a role binding for the service account and role") @@ -316,31 +330,47 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. ) switch { case err == nil: + original := newOnce(proxySecret.DeepCopy) desiredListenerProxy, err := r.newAutoscalingListenerProxySecret(&autoscalingListener, proxySecret.Data) if err != nil { log.Error(err, "Failed to build desired listener proxy secret") return ctrl.Result{}, err } - desiredLabels := r.filterAndMergeLabels(proxySecret.Labels, desiredListenerProxy.Labels) - labelsModified := !maps.Equal(proxySecret.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(proxySecret.Annotations, desiredListenerProxy.Annotations) - annotationsModified := !maps.Equal(proxySecret.Annotations, desiredAnnotations) - var original once[*corev1.Secret] + desiredLabels, labelsModified := r.mergeLabels(proxySecret.Labels, desiredListenerProxy.Labels) if labelsModified { - original.Do(proxySecret.DeepCopy) + original.Do() proxySecret.Labels = desiredLabels } + desiredAnnotations, annotationsModified := r.mergeAnnotations(proxySecret.Annotations, desiredListenerProxy.Annotations) if annotationsModified { - original.Do(proxySecret.DeepCopy) + original.Do() proxySecret.Annotations = desiredAnnotations } - if labelsModified || annotationsModified { + // we set the data so we just need to check other fields are nil + if proxySecret.Immutable != nil { + original.Do() + proxySecret.Immutable = nil + } + if proxySecret.StringData != nil { + original.Do() + proxySecret.StringData = nil + } + if proxySecret.Type != desiredListenerProxy.Type { + original.Do() + proxySecret.Type = desiredListenerProxy.Type + } + dataModified := !cmp.Equal(proxySecret.Data, desiredListenerProxy.Data) + if dataModified { + original.Do() + proxySecret.Data = desiredListenerProxy.Data + } + + if original.Called() { log.Info("Updating listener proxy secret") if err := r.Patch(ctx, &proxySecret, client.MergeFrom(original.Get())); err != nil { log.Error(err, "Failed to update listener proxy secret") return ctrl.Result{}, err } - return ctrl.Result{Requeue: true}, nil } case kerrors.IsNotFound(err): // Create a mirror secret for the listener pod in the Controller namespace for listener pod to use @@ -363,10 +393,8 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. log.Error( err, "Failed to get app config for AutoscalingRunnerSet.", - "namespace", - autoscalingRunnerSet.Namespace, - "name", - autoscalingRunnerSet.GitHubConfigSecret, + "namespace", autoscalingRunnerSet.Namespace, + "name", autoscalingRunnerSet.GitHubConfigSecret, ) return nil, err } @@ -394,6 +422,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. ) switch { case err == nil: + original := newOnce(listenerConfigSecret.DeepCopy) cfg, err := r.GetAppConfig(ctx, &autoscalingRunnerSet) if err != nil { return ctrl.Result{}, err @@ -409,21 +438,31 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. if err != nil { return ctrl.Result{}, fmt.Errorf("failed to build listener config secret: %w", err) } - desiredLabels := r.filterAndMergeLabels(listenerConfigSecret.Labels, desiredSecret.Labels) - labelsModified := !maps.Equal(listenerConfigSecret.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(listenerConfigSecret.Annotations, desiredSecret.Annotations) - annotationsModified := !maps.Equal(listenerConfigSecret.Annotations, desiredAnnotations) - var original once[*corev1.Secret] + desiredLabels, labelsModified := r.mergeLabels(listenerConfigSecret.Labels, desiredSecret.Labels) if labelsModified { - original.Do(listenerConfigSecret.DeepCopy) + original.Do() listenerConfigSecret.Labels = desiredLabels } + desiredAnnotations, annotationsModified := r.mergeAnnotations(listenerConfigSecret.Annotations, desiredSecret.Annotations) if annotationsModified { - original.Do(listenerConfigSecret.DeepCopy) + original.Do() listenerConfigSecret.Annotations = desiredAnnotations } + // we set the data so we just need to check other fields are nil + if listenerConfigSecret.Immutable != nil { + original.Do() + listenerConfigSecret.Immutable = nil + } + if listenerConfigSecret.StringData != nil { + original.Do() + listenerConfigSecret.StringData = nil + } + if listenerConfigSecret.Type != desiredSecret.Type { + original.Do() + listenerConfigSecret.Type = desiredSecret.Type + } - if labelsModified || annotationsModified { + if original.Called() { log.Info("Updating listener config secret", "namespace", listenerConfigSecret.Namespace, "name", listenerConfigSecret.Name) if err := r.Patch(ctx, &listenerConfigSecret, client.MergeFrom(original.Get())); err != nil { return ctrl.Result{}, fmt.Errorf("failed to update listener config secret: %w", err) @@ -454,7 +493,7 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. } // Requeue to create listener pod with the config secret - return ctrl.Result{Requeue: true}, nil + return ctrl.Result{RequeueAfter: 100 * time.Millisecond}, nil default: log.Error(err, "Unable to get listener config secret", "namespace", autoscalingListener.Namespace, "name", scaleSetListenerConfigName(&autoscalingListener)) return ctrl.Result{}, err @@ -484,17 +523,15 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. return ctrl.Result{}, err } - desiredLabels := r.filterAndMergeLabels(listenerPod.Labels, desiredPod.Labels) - labelsModified := !maps.Equal(listenerPod.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(listenerPod.Annotations, desiredPod.Annotations) - annotationsModified := !maps.Equal(listenerPod.Annotations, desiredAnnotations) - var original once[*corev1.Pod] + original := newOnce(listenerPod.DeepCopy) + desiredLabels, labelsModified := r.mergeLabels(listenerPod.Labels, desiredPod.Labels) if labelsModified { - original.Do(listenerPod.DeepCopy) + original.Do() listenerPod.Labels = desiredLabels } + desiredAnnotations, annotationsModified := r.mergeAnnotations(listenerPod.Annotations, desiredPod.Annotations) if annotationsModified { - original.Do(listenerPod.DeepCopy) + original.Do() listenerPod.Annotations = desiredAnnotations } diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller.go b/controllers/actions.github.com/autoscalingrunnerset_controller.go index 5b98e2c562..2a496d687a 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller.go @@ -19,7 +19,6 @@ package actionsgithubcom import ( "context" "fmt" - "maps" "strconv" "strings" "time" @@ -74,7 +73,7 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl if err := r.Get(ctx, req.NamespacedName, &autoscalingRunnerSet); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } - var original once[*v1alpha1.AutoscalingRunnerSet] + original := newOnce(autoscalingRunnerSet.DeepCopy) if !autoscalingRunnerSet.DeletionTimestamp.IsZero() { if !controllerutil.ContainsFinalizer(&autoscalingRunnerSet, autoscalingRunnerSetFinalizerName) { @@ -101,7 +100,7 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl removeFinalizer := controllerutil.ContainsFinalizer(&autoscalingRunnerSet, autoscalingRunnerSetFinalizerName) if removeFinalizer { - original.Do(autoscalingRunnerSet.DeepCopy) + original.Do() controllerutil.RemoveFinalizer(&autoscalingRunnerSet, autoscalingRunnerSetFinalizerName) } if removeFinalizer { @@ -137,7 +136,7 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl addFinalizer := !controllerutil.ContainsFinalizer(&autoscalingRunnerSet, autoscalingRunnerSetFinalizerName) if addFinalizer { - original.Do(autoscalingRunnerSet.DeepCopy) + original.Do() controllerutil.AddFinalizer(&autoscalingRunnerSet, autoscalingRunnerSetFinalizerName) } if addFinalizer { @@ -205,15 +204,15 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl return ctrl.Result{}, nil } - var original once[*v1alpha1.EphemeralRunnerSet] + original := newOnce(ephemeralRunnerSet.DeepCopy) ephemeralRunnerReplicasModified := ephemeralRunnerSet.Spec.Replicas != 0 if ephemeralRunnerReplicasModified { - original.Do(ephemeralRunnerSet.DeepCopy) + original.Do() ephemeralRunnerSet.Spec.Replicas = 0 } ephemeralRunnerPatchIDModified := ephemeralRunnerSet.Spec.PatchID != 0 if ephemeralRunnerPatchIDModified { - original.Do(ephemeralRunnerSet.DeepCopy) + original.Do() ephemeralRunnerSet.Spec.PatchID = 0 } if ephemeralRunnerReplicasModified || ephemeralRunnerPatchIDModified { @@ -302,29 +301,29 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl return ctrl.Result{}, nil } - var original once[*v1alpha1.EphemeralRunnerSet] + original := newOnce(ephemeralRunnerSet.DeepCopy) ephemeralRunnerActionableSpecModified := !cmp.Equal(ephemeralRunnerSet.Spec.EphemeralRunnerSpec, desired.Spec.EphemeralRunnerSpec) if ephemeralRunnerActionableSpecModified { - original.Do(ephemeralRunnerSet.DeepCopy) + original.Do() ephemeralRunnerSet.Spec.EphemeralRunnerSpec = desired.Spec.EphemeralRunnerSpec ephemeralRunnerSet.Spec.ActionableRevision = nextActionableRevision(&ephemeralRunnerSet) } ephemeralRunnerMetadataModified := !cmp.Equal(ephemeralRunnerSet.Spec.EphemeralRunnerMetadata, desired.Spec.EphemeralRunnerMetadata) if ephemeralRunnerMetadataModified { - original.Do(ephemeralRunnerSet.DeepCopy) + original.Do() ephemeralRunnerSet.Spec.EphemeralRunnerMetadata = desired.Spec.EphemeralRunnerMetadata } - ephemeralRunnerLabelsModified := !maps.Equal(ephemeralRunnerSet.Labels, desired.Labels) + desiredLabels, ephemeralRunnerLabelsModified := r.mergeLabels(ephemeralRunnerSet.Labels, desired.Labels) if ephemeralRunnerLabelsModified { - original.Do(ephemeralRunnerSet.DeepCopy) - ephemeralRunnerSet.Labels = r.filterAndMergeLabels(ephemeralRunnerSet.Labels, desired.Labels) + original.Do() + ephemeralRunnerSet.Labels = desiredLabels } - ephemeralRunnerAnnotationsModified := !maps.Equal(ephemeralRunnerSet.Annotations, desired.Annotations) + desiredAnnotations, ephemeralRunnerAnnotationsModified := r.mergeAnnotations(ephemeralRunnerSet.Annotations, desired.Annotations) if ephemeralRunnerAnnotationsModified { - original.Do(ephemeralRunnerSet.DeepCopy) - ephemeralRunnerSet.Annotations = r.mergeAnnotations(ephemeralRunnerSet.Annotations, desired.Annotations) + original.Do() + ephemeralRunnerSet.Annotations = desiredAnnotations } if ephemeralRunnerActionableSpecModified || ephemeralRunnerLabelsModified || ephemeralRunnerAnnotationsModified || ephemeralRunnerMetadataModified { @@ -373,9 +372,20 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl return ctrl.Result{}, nil } - if !cmp.Equal(listener.Spec, desired.Spec) || - !cmp.Equal(listener.Labels, desired.Labels) || - !cmp.Equal(listener.Annotations, desired.Annotations) { + original := newOnce(listener.DeepCopy) + desiredLabels, listnerLabelsModified := r.mergeLabels(listener.Labels, desired.Labels) + if listnerLabelsModified { + original.Do() + listener.Labels = desiredLabels + } + + desiredAnnotations, listenerAnnotationsModified := r.mergeAnnotations(listener.Annotations, desired.Annotations) + if listenerAnnotationsModified { + original.Do() + listener.Annotations = desiredAnnotations + } + + if !cmp.Equal(listener.Spec, desired.Spec) { log.Info("Deleting AutoscalingListener to re-create with updated spec") if err := r.Delete(ctx, &listener); err != nil { log.Error(err, "Failed to delete AutoscalingListener for re-creation") @@ -384,6 +394,16 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl log.Info("Deleted AutoscalingListener, will re-create on next reconcile") return ctrl.Result{}, nil } + + if original.Called() { + log.Info("Updating AutoscalingListener metadata to match desired labels and annotations") + if err := r.Patch(ctx, &listener, client.MergeFrom(original.Get())); err != nil { + log.Error(err, "Failed to patch AutoscalingListener metadata") + return ctrl.Result{}, err + } + log.Info("Successfully patched AutoscalingListener metadata") + return ctrl.Result{}, nil + } } log.Info("Autoscaling runner set is up to date and ready") @@ -536,11 +556,11 @@ func (r *AutoscalingRunnerSetReconciler) removeFinalizersFromDependentResources( } func (r *AutoscalingRunnerSetReconciler) createRunnerScaleSet(ctx context.Context, autoscalingRunnerSet *v1alpha1.AutoscalingRunnerSet, logger logr.Logger) (ctrl.Result, error) { - var original once[*v1alpha1.AutoscalingRunnerSet] + original := newOnce(autoscalingRunnerSet.DeepCopy) logger.Info("Creating a new runner scale set") actionsClient, err := r.GetActionsService(ctx, autoscalingRunnerSet) if len(autoscalingRunnerSet.Spec.RunnerScaleSetName) == 0 { - original.Do(autoscalingRunnerSet.DeepCopy) + original.Do() autoscalingRunnerSet.Spec.RunnerScaleSetName = autoscalingRunnerSet.Name } if err != nil { @@ -615,7 +635,7 @@ func (r *AutoscalingRunnerSetReconciler) createRunnerScaleSet(ctx context.Contex actionsClient.SetSystemInfo(info) logger.Info("Created/Reused a runner scale set", "id", runnerScaleSet.ID, "runnerGroupName", runnerScaleSet.RunnerGroupName) - original.Do(autoscalingRunnerSet.DeepCopy) + original.Do() if autoscalingRunnerSet.Annotations == nil { autoscalingRunnerSet.Annotations = map[string]string{} } diff --git a/controllers/actions.github.com/ephemeralrunner_controller.go b/controllers/actions.github.com/ephemeralrunner_controller.go index 07dfe7de81..88b5faedcf 100644 --- a/controllers/actions.github.com/ephemeralrunner_controller.go +++ b/controllers/actions.github.com/ephemeralrunner_controller.go @@ -94,7 +94,7 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ if err := r.Get(ctx, req.NamespacedName, &ephemeralRunner); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } - var original once[*v1alpha1.EphemeralRunner] + original := newOnce(ephemeralRunner.DeepCopy) if !ephemeralRunner.DeletionTimestamp.IsZero() { r.publishEphemeralRunnerPhaseMetric(&ephemeralRunner, "", log) @@ -117,7 +117,7 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ } log.Info("Runner is cleaned up from the service, removing finalizer") - original.Do(ephemeralRunner.DeepCopy) + original.Do() controllerutil.RemoveFinalizer(&ephemeralRunner, ephemeralRunnerActionsFinalizerName) } if removeActionsFinalizer { @@ -147,7 +147,7 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ removeFinalizer := controllerutil.ContainsFinalizer(&ephemeralRunner, ephemeralRunnerFinalizerName) if removeFinalizer { - original.Do(ephemeralRunner.DeepCopy) + original.Do() controllerutil.RemoveFinalizer(&ephemeralRunner, ephemeralRunnerFinalizerName) } if removeFinalizer { @@ -181,12 +181,12 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ ephemeralRunnerFinalizerModified := !controllerutil.ContainsFinalizer(&ephemeralRunner, ephemeralRunnerFinalizerName) if ephemeralRunnerFinalizerModified { - original.Do(ephemeralRunner.DeepCopy) + original.Do() controllerutil.AddFinalizer(&ephemeralRunner, ephemeralRunnerFinalizerName) } ephemeralRunnerActionsFinalizerModified := !controllerutil.ContainsFinalizer(&ephemeralRunner, ephemeralRunnerActionsFinalizerName) if ephemeralRunnerActionsFinalizerModified { - original.Do(ephemeralRunner.DeepCopy) + original.Do() controllerutil.AddFinalizer(&ephemeralRunner, ephemeralRunnerActionsFinalizerName) } if ephemeralRunnerFinalizerModified || ephemeralRunnerActionsFinalizerModified { diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller.go b/controllers/actions.github.com/ephemeralrunnerset_controller.go index 0748f6ca73..275ae7a73c 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller.go @@ -80,7 +80,7 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R if err := r.Get(ctx, req.NamespacedName, &ephemeralRunnerSet); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } - var original once[*v1alpha1.EphemeralRunnerSet] + original := newOnce(ephemeralRunnerSet.DeepCopy) // Requested deletion does not need reconciled. if !ephemeralRunnerSet.DeletionTimestamp.IsZero() { @@ -111,7 +111,7 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R removeFinalizer := controllerutil.ContainsFinalizer(&ephemeralRunnerSet, EphemeralRunnerSetFinalizerName) if removeFinalizer { - original.Do(ephemeralRunnerSet.DeepCopy) + original.Do() controllerutil.RemoveFinalizer(&ephemeralRunnerSet, EphemeralRunnerSetFinalizerName) } if removeFinalizer { @@ -130,7 +130,7 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R // Add finalizer if not present addFinalizer := !controllerutil.ContainsFinalizer(&ephemeralRunnerSet, EphemeralRunnerSetFinalizerName) if addFinalizer { - original.Do(ephemeralRunnerSet.DeepCopy) + original.Do() controllerutil.AddFinalizer(&ephemeralRunnerSet, EphemeralRunnerSetFinalizerName) } if addFinalizer { @@ -538,21 +538,19 @@ func (r *EphemeralRunnerSetReconciler) reconcileEphemeralRunnerSetProxySecret(ct } dataModified := !maps.EqualFunc(proxySecret.Data, desiredRunnerSetProxy.Data, bytes.Equal) - desiredLabels := r.filterAndMergeLabels(proxySecret.Labels, desiredRunnerSetProxy.Labels) - labelsModified := !maps.Equal(proxySecret.Labels, desiredLabels) - desiredAnnotations := r.mergeAnnotations(proxySecret.Annotations, desiredRunnerSetProxy.Annotations) - annotationsModified := !maps.Equal(proxySecret.Annotations, desiredAnnotations) - var original once[*corev1.Secret] + desiredLabels, labelsModified := r.mergeLabels(proxySecret.Labels, desiredRunnerSetProxy.Labels) + desiredAnnotations, annotationsModified := r.mergeAnnotations(proxySecret.Annotations, desiredRunnerSetProxy.Annotations) + original := newOnce(proxySecret.DeepCopy) if dataModified { - original.Do(proxySecret.DeepCopy) + original.Do() proxySecret.Data = desiredRunnerSetProxy.Data } if labelsModified { - original.Do(proxySecret.DeepCopy) + original.Do() proxySecret.Labels = desiredLabels } if annotationsModified { - original.Do(proxySecret.DeepCopy) + original.Do() proxySecret.Annotations = desiredAnnotations } if dataModified || labelsModified || annotationsModified { diff --git a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go index 6f9942e6dd..b5b6ed149b 100644 --- a/controllers/actions.github.com/ephemeralrunnerset_controller_test.go +++ b/controllers/actions.github.com/ephemeralrunnerset_controller_test.go @@ -1670,6 +1670,28 @@ var _ = Describe("Test EphemeralRunnerSet actionable revision cleanup", func() { } } + waitForCachedActionableRevision := func(controller *EphemeralRunnerSetReconciler, key types.NamespacedName, specRevision, appliedRevision int64) { + Eventually(func(g Gomega) { + cached := new(v1alpha1.EphemeralRunnerSet) + g.Expect(controller.Get(ctx, key, cached)).To(Succeed()) + g.Expect(cached.Spec.ActionableRevision).To(Equal(specRevision)) + g.Expect(cached.Status.AppliedActionableRevision).To(Equal(appliedRevision)) + g.Expect(cached.Finalizers).To(ContainElement(EphemeralRunnerSetFinalizerName)) + }, ephemeralRunnerSetTestTimeout, ephemeralRunnerSetTestInterval).Should(Succeed()) + } + + waitForCachedRunningRunners := func(controller *EphemeralRunnerSetReconciler, namespace, owner string, expected int) { + Eventually(func(g Gomega) { + runners := new(v1alpha1.EphemeralRunnerList) + g.Expect(controller.List(ctx, runners, client.InNamespace(namespace), client.MatchingFields{resourceOwnerKey: owner})).To(Succeed()) + state := newEphemeralRunnersByStates(runners) + g.Expect(state.running).To(HaveLen(expected)) + for _, runner := range state.running { + g.Expect(runner.Status.RunnerID).NotTo(BeZero()) + } + }, ephemeralRunnerSetTestTimeout, ephemeralRunnerSetTestInterval).Should(Succeed()) + } + BeforeEach(func() { ctx = context.Background() autoscalingNS, mgr = createNamespace(GinkgoT(), k8sClient) @@ -1904,6 +1926,9 @@ var _ = Describe("Test EphemeralRunnerSet actionable revision cleanup", func() { err = k8sClient.Patch(ctx, specUpdated, client.MergeFrom(current)) Expect(err).NotTo(HaveOccurred()) + waitForCachedActionableRevision(controller, request.NamespacedName, 4, 3) + waitForCachedRunningRunners(controller, autoscalingNS.Name, ephemeralRunnerSet.Name, 1) + _, err = controller.Reconcile(ctx, request) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("remove failed")) diff --git a/controllers/actions.github.com/resourcebuilder.go b/controllers/actions.github.com/resourcebuilder.go index 3657cfaf84..c5136d4579 100644 --- a/controllers/actions.github.com/resourcebuilder.go +++ b/controllers/actions.github.com/resourcebuilder.go @@ -163,7 +163,7 @@ func (b *ResourceBuilder) newAutoscalingListener(autoscalingRunnerSet *v1alpha1. ConfigSecretMetadata: autoscalingRunnerSet.Spec.ListenerConfigSecretMetadata, } - labels := b.filterAndMergeLabels(autoscalingRunnerSet.Labels, map[string]string{ + labels := b.propagateLabels(autoscalingRunnerSet.Labels, map[string]string{ LabelKeyGitHubScaleSetNamespace: autoscalingRunnerSet.Namespace, LabelKeyGitHubScaleSetName: autoscalingRunnerSet.Name, LabelKeyKubernetesPartOf: labelValueKubernetesPartOf, @@ -178,8 +178,8 @@ func (b *ResourceBuilder) newAutoscalingListener(autoscalingRunnerSet *v1alpha1. var annotations map[string]string if autoscalingRunnerSet.Spec.AutoscalingListenerMetadata != nil { - labels = b.filterAndMergeLabels(autoscalingRunnerSet.Spec.AutoscalingListenerMetadata.Labels, labels) - annotations = b.mergeAnnotations(autoscalingRunnerSet.Spec.AutoscalingListenerMetadata.Annotations, annotations) + labels, _ = b.mergeLabels(autoscalingRunnerSet.Spec.AutoscalingListenerMetadata.Labels, labels) + annotations, _ = b.mergeAnnotations(autoscalingRunnerSet.Spec.AutoscalingListenerMetadata.Annotations, annotations) } autoscalingListener := &v1alpha1.AutoscalingListener{ @@ -285,7 +285,7 @@ func (b *ResourceBuilder) newScaleSetListenerConfig(autoscalingListener *v1alpha var labels map[string]string if autoscalingListener.Spec.ConfigSecretMetadata != nil && len(autoscalingListener.Spec.ConfigSecretMetadata.Labels) > 0 { - labels = b.filterAndMergeLabels(autoscalingListener.Spec.ConfigSecretMetadata.Labels, nil) + labels = autoscalingListener.Spec.ConfigSecretMetadata.Labels } annotations := make(map[string]string) @@ -307,6 +307,7 @@ func (b *ResourceBuilder) newScaleSetListenerConfig(autoscalingListener *v1alpha Data: map[string][]byte{ "config.json": buf.Bytes(), }, + Type: corev1.SecretTypeOpaque, } if err := b.setControllerReference(autoscalingListener, desiredSecret); err != nil { @@ -593,27 +594,29 @@ func (b *ResourceBuilder) newScaleSetListenerServiceAccount(autoscalingListener return cached, nil } + labels := b.propagateLabels(autoscalingListener.Labels, map[string]string{ + LabelKeyGitHubScaleSetNamespace: autoscalingListener.Spec.AutoscalingRunnerSetNamespace, + LabelKeyGitHubScaleSetName: autoscalingListener.Spec.AutoscalingRunnerSetName, + }) + annotations := make(map[string]string) + + if autoscalingListener.Spec.ServiceAccountMetadata != nil { + labels, _ = b.mergeLabels(autoscalingListener.Spec.ServiceAccountMetadata.Labels, labels) + annotations, _ = b.mergeAnnotations(autoscalingListener.Spec.ServiceAccountMetadata.Annotations, annotations) + } + base := &corev1.ServiceAccount{ TypeMeta: metav1.TypeMeta{ APIVersion: corev1.SchemeGroupVersion.String(), Kind: "ServiceAccount", }, ObjectMeta: metav1.ObjectMeta{ - Name: autoscalingListener.Name, - Namespace: autoscalingListener.Namespace, - Labels: b.filterAndMergeLabels(autoscalingListener.Labels, map[string]string{ - LabelKeyGitHubScaleSetNamespace: autoscalingListener.Spec.AutoscalingRunnerSetNamespace, - LabelKeyGitHubScaleSetName: autoscalingListener.Spec.AutoscalingRunnerSetName, - }), - Annotations: make(map[string]string), + Name: autoscalingListener.Name, + Namespace: autoscalingListener.Namespace, + Labels: labels, + Annotations: annotations, }, } - - if autoscalingListener.Spec.ServiceAccountMetadata != nil { - base.Labels = b.filterAndMergeLabels(autoscalingListener.Spec.ServiceAccountMetadata.Labels, base.Labels) - base.Annotations = b.mergeAnnotations(autoscalingListener.Spec.ServiceAccountMetadata.Annotations, base.Annotations) - } - if err := b.setControllerReference(autoscalingListener, base); err != nil { return nil, fmt.Errorf("failed to set controller reference for listener service account: %w", err) } @@ -633,7 +636,7 @@ func (b *ResourceBuilder) newScaleSetListenerRole(autoscalingListener *v1alpha1. return cached } - labels := b.filterAndMergeLabels(autoscalingListener.Labels, map[string]string{ + labels := b.propagateLabels(autoscalingListener.Labels, map[string]string{ LabelKeyGitHubScaleSetNamespace: autoscalingListener.Spec.AutoscalingRunnerSetNamespace, LabelKeyGitHubScaleSetName: autoscalingListener.Spec.AutoscalingRunnerSetName, labelKeyListenerNamespace: autoscalingListener.Namespace, @@ -642,8 +645,8 @@ func (b *ResourceBuilder) newScaleSetListenerRole(autoscalingListener *v1alpha1. annotations := make(map[string]string) if autoscalingListener.Spec.RoleMetadata != nil { - labels = b.filterAndMergeLabels(autoscalingListener.Spec.RoleMetadata.Labels, labels) - annotations = b.mergeAnnotations(autoscalingListener.Spec.RoleMetadata.Annotations, nil) + labels, _ = b.mergeLabels(autoscalingListener.Spec.RoleMetadata.Labels, labels) + annotations = autoscalingListener.Spec.RoleMetadata.Annotations } newRole := &rbacv1.Role{ @@ -677,8 +680,9 @@ func (b *ResourceBuilder) newScaleSetListenerRoleBinding(autoscalingListener *v1 } roleRef := rbacv1.RoleRef{ - Kind: "Role", - Name: listenerRole.Name, + APIGroup: rbacv1.GroupName, + Kind: "Role", + Name: listenerRole.Name, } subjects := []rbacv1.Subject{ @@ -689,7 +693,7 @@ func (b *ResourceBuilder) newScaleSetListenerRoleBinding(autoscalingListener *v1 }, } - labels := b.filterAndMergeLabels(autoscalingListener.Labels, map[string]string{ + labels := b.propagateLabels(autoscalingListener.Labels, map[string]string{ LabelKeyGitHubScaleSetNamespace: autoscalingListener.Spec.AutoscalingRunnerSetNamespace, LabelKeyGitHubScaleSetName: autoscalingListener.Spec.AutoscalingRunnerSetName, labelKeyListenerNamespace: autoscalingListener.Namespace, @@ -698,7 +702,7 @@ func (b *ResourceBuilder) newScaleSetListenerRoleBinding(autoscalingListener *v1 annotations := make(map[string]string) if autoscalingListener.Spec.RoleBindingMetadata != nil { - labels = b.filterAndMergeLabels(autoscalingListener.Spec.RoleBindingMetadata.Labels, labels) + labels, _ = b.mergeLabels(autoscalingListener.Spec.RoleBindingMetadata.Labels, labels) annotations = autoscalingListener.Spec.RoleBindingMetadata.Annotations } @@ -753,7 +757,7 @@ func (b *ResourceBuilder) newEphemeralRunnerSet(autoscalingRunnerSet *v1alpha1.A EphemeralRunnerMetadata: autoscalingRunnerSet.Spec.EphemeralRunnerMetadata, } - labels := b.filterAndMergeLabels(autoscalingRunnerSet.Labels, map[string]string{ + labels := b.propagateLabels(autoscalingRunnerSet.Labels, map[string]string{ LabelKeyKubernetesPartOf: labelValueKubernetesPartOf, LabelKeyKubernetesComponent: "runner-set", LabelKeyKubernetesVersion: autoscalingRunnerSet.Labels[LabelKeyKubernetesVersion], @@ -771,8 +775,8 @@ func (b *ResourceBuilder) newEphemeralRunnerSet(autoscalingRunnerSet *v1alpha1.A } if autoscalingRunnerSet.Spec.EphemeralRunnerSetMetadata != nil { - labels = b.filterAndMergeLabels(autoscalingRunnerSet.Spec.EphemeralRunnerSetMetadata.Labels, labels) - annotations = b.mergeAnnotations(autoscalingRunnerSet.Spec.EphemeralRunnerSetMetadata.Annotations, annotations) + labels, _ = b.mergeLabels(autoscalingRunnerSet.Spec.EphemeralRunnerSetMetadata.Labels, labels) + annotations, _ = b.mergeAnnotations(autoscalingRunnerSet.Spec.EphemeralRunnerSetMetadata.Annotations, annotations) } newEphemeralRunnerSet := &v1alpha1.EphemeralRunnerSet{ @@ -809,6 +813,7 @@ func (b *ResourceBuilder) newAutoscalingListenerProxySecret(autoscalingListener Annotations: make(map[string]string, 1), }, Data: data, + Type: corev1.SecretTypeOpaque, } if err := b.setControllerReference(autoscalingListener, newProxySecret); err != nil { @@ -828,8 +833,8 @@ func (b *ResourceBuilder) newEphemeralRunner(ephemeralRunnerSet *v1alpha1.Epheme annotations[AnnotationKeyPatchID] = strconv.Itoa(ephemeralRunnerSet.Spec.PatchID) if ephemeralRunnerSet.Spec.EphemeralRunnerMetadata != nil { - labels = b.filterAndMergeLabels(ephemeralRunnerSet.Spec.EphemeralRunnerMetadata.Labels, labels) - annotations = b.mergeAnnotations(ephemeralRunnerSet.Spec.EphemeralRunnerMetadata.Annotations, annotations) + labels, _ = b.mergeLabels(ephemeralRunnerSet.Spec.EphemeralRunnerMetadata.Labels, labels) + annotations, _ = b.mergeAnnotations(ephemeralRunnerSet.Spec.EphemeralRunnerMetadata.Annotations, annotations) } ephemeralRunner := &v1alpha1.EphemeralRunner{ @@ -925,7 +930,7 @@ func (b *ResourceBuilder) newEphemeralRunnerJitSecret(ephemeralRunner *v1alpha1. ) if ephemeralRunner.Spec.EphemeralRunnerConfigSecretMetadata != nil { - labels = b.filterAndMergeLabels(ephemeralRunner.Spec.EphemeralRunnerConfigSecretMetadata.Labels, nil) + labels = ephemeralRunner.Spec.EphemeralRunnerConfigSecretMetadata.Labels annotations = ephemeralRunner.Spec.EphemeralRunnerConfigSecretMetadata.Annotations } @@ -1051,39 +1056,67 @@ func trimLabelValue(val string) string { return strings.Trim(val, "-_.") } -func (b *ResourceBuilder) filterAndMergeLabels(base, overwrite map[string]string) map[string]string { +func (b *ResourceBuilder) shouldExcludePropagatedLabel(k, _ string) bool { + for _, prefix := range b.ExcludeLabelPropagationPrefixes { + if strings.HasPrefix(k, prefix) { + return true + } + } + return false +} + +// propagateLabels is responsible for filtering labels during propagation. +// It only makes sense when we are creating the resource derived from some other resource. +// Since the desired resource is cached, then we don't need to call this method every time. +func (b *ResourceBuilder) propagateLabels(base, overwrite map[string]string) map[string]string { if base == nil && overwrite == nil { return nil } - mergedLabels := make(map[string]string, len(base)) -base: + labels := make(map[string]string, len(base)+len(overwrite)) for k, v := range base { - for _, prefix := range b.ExcludeLabelPropagationPrefixes { - if strings.HasPrefix(k, prefix) { - continue base - } + if b.shouldExcludePropagatedLabel(k, v) { + continue } - mergedLabels[k] = v + labels[k] = v } + maps.Copy(labels, overwrite) -overwrite: - for k, v := range overwrite { - for _, prefix := range b.ExcludeLabelPropagationPrefixes { - if strings.HasPrefix(k, prefix) { - continue overwrite - } - } - mergedLabels[k] = v + if len(labels) == 0 { + return nil } + return labels +} - return mergedLabels +func (b *ResourceBuilder) mergeLabels(base, overwrite map[string]string) (map[string]string, bool) { + return mergeMaps(base, overwrite) } -func (b *ResourceBuilder) mergeAnnotations(base, overwrite map[string]string) map[string]string { - if base == nil && overwrite == nil { - return nil +func (b *ResourceBuilder) mergeAnnotations(base, overwrite map[string]string) (map[string]string, bool) { + return mergeMaps(base, overwrite) +} + +func mergeMaps[M ~map[K]V, K comparable, V comparable](base M, overwrite M) (M, bool) { + if len(overwrite) == 0 { + return base, false + } + + if containsMapEntries(base, overwrite) { + return base, false + } + + merged := make(M, len(base)+len(overwrite)) + maps.Copy(merged, base) + maps.Copy(merged, overwrite) + return merged, true +} + +func containsMapEntries[M ~map[K]V, K comparable, V comparable](base M, entries M) bool { + for k, v := range entries { + current, ok := base[k] + if !ok || current != v { + return false + } } - maps.Copy(base, overwrite) - return base + return true } diff --git a/controllers/actions.github.com/resourcebuilder_test.go b/controllers/actions.github.com/resourcebuilder_test.go index 9e91790d6c..3a3ad4cf15 100644 --- a/controllers/actions.github.com/resourcebuilder_test.go +++ b/controllers/actions.github.com/resourcebuilder_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -157,6 +158,7 @@ func TestMetadataPropagation(t *testing.T) { assert.Equal(t, "listener-role-annotation", listenerRole.Annotations["test.com/listener-role-annotation"]) listenerRoleBinding := b.newScaleSetListenerRoleBinding(listener, listenerRole, listenerServiceAccount) + assert.Equal(t, rbacv1.GroupName, listenerRoleBinding.RoleRef.APIGroup) assert.Equal(t, "listener-role-binding-label", listenerRoleBinding.Labels["test.com/listener-role-binding-label"]) assert.Equal(t, "listener-role-binding-annotation", listenerRoleBinding.Annotations["test.com/listener-role-binding-annotation"]) @@ -231,6 +233,75 @@ func TestEphemeralRunnerSetProxySecretMetadata(t *testing.T) { assert.NotContains(t, proxySecret.Annotations, "actions.github.com/integrity-hash") } +func TestMergeMaps(t *testing.T) { + t.Run("equal overwrite is not modified", func(t *testing.T) { + base := map[string]string{ + "a": "1", + "b": "2", + } + overwrite := map[string]string{ + "a": "1", + "b": "2", + } + + merged, modified := mergeMaps(base, overwrite) + + require.False(t, modified) + merged["a"] = "changed" + assert.Equal(t, "changed", base["a"]) + }) + + t.Run("base superset is not modified", func(t *testing.T) { + base := map[string]string{ + "a": "1", + "b": "2", + "extra": "base-only", + } + overwrite := map[string]string{ + "a": "1", + "b": "2", + } + + merged, modified := mergeMaps(base, overwrite) + + require.False(t, modified) + merged["a"] = "changed" + assert.Equal(t, "changed", base["a"]) + }) + + t.Run("missing overwrite key is modified", func(t *testing.T) { + base := map[string]string{ + "a": "1", + } + overwrite := map[string]string{ + "a": "1", + "b": "2", + } + + merged, modified := mergeMaps(base, overwrite) + + require.True(t, modified) + assert.Equal(t, map[string]string{"a": "1", "b": "2"}, merged) + assert.NotContains(t, base, "b") + }) + + t.Run("different overwrite value is modified", func(t *testing.T) { + base := map[string]string{ + "a": "1", + "b": "2", + } + overwrite := map[string]string{ + "a": "updated", + } + + merged, modified := mergeMaps(base, overwrite) + + require.True(t, modified) + assert.Equal(t, map[string]string{"a": "updated", "b": "2"}, merged) + assert.Equal(t, "1", base["a"]) + }) +} + func TestGitHubURLTrimLabelValues(t *testing.T) { enterprise := strings.Repeat("a", 64) organization := strings.Repeat("b", 64) diff --git a/controllers/actions.github.com/utils.go b/controllers/actions.github.com/utils.go index c17aba6d7d..482c95d45f 100644 --- a/controllers/actions.github.com/utils.go +++ b/controllers/actions.github.com/utils.go @@ -16,21 +16,31 @@ func FilterLabels(labels map[string]string, filter string) map[string]string { type once[T client.Object] struct { value T - fn func(T) *T + fn func() T done bool } -func (o *once[T]) Do(f func() T) T { +func newOnce[T client.Object](fn func() T) *once[T] { + return &once[T]{ + fn: fn, + } +} + +func (o *once[T]) Do() T { if !o.done { - o.value = f() + o.value = o.fn() o.done = true } return o.value } func (o *once[T]) Get() T { - if !o.done { + if !o.Called() { panic("not done") } return o.value } + +func (o *once[T]) Called() bool { + return o.done +} From 077d08d089e5bfb0d94884f7b70942d77da49edd Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Fri, 24 Jul 2026 12:25:29 +0200 Subject: [PATCH 31/31] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../actions.github.com/autoscalinglistener_controller.go | 4 ++-- .../actions.github.com/autoscalingrunnerset_controller.go | 4 ++-- controllers/actions.github.com/utils.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/controllers/actions.github.com/autoscalinglistener_controller.go b/controllers/actions.github.com/autoscalinglistener_controller.go index 3dc1c63c7b..120ae1e0e2 100644 --- a/controllers/actions.github.com/autoscalinglistener_controller.go +++ b/controllers/actions.github.com/autoscalinglistener_controller.go @@ -283,8 +283,8 @@ func (r *AutoscalingListenerReconciler) Reconcile(ctx context.Context, req ctrl. original.Do() listenerRoleBinding.Annotations = desiredAnnotations } - rulesModified := !cmp.Equal(listenerRoleBinding.RoleRef, desiredRoleBinding.RoleRef) - if rulesModified { + roleRefModified := !cmp.Equal(listenerRoleBinding.RoleRef, desiredRoleBinding.RoleRef) + if roleRefModified { original.Do() listenerRoleBinding.RoleRef = desiredRoleBinding.RoleRef } diff --git a/controllers/actions.github.com/autoscalingrunnerset_controller.go b/controllers/actions.github.com/autoscalingrunnerset_controller.go index 2a496d687a..3425176d35 100644 --- a/controllers/actions.github.com/autoscalingrunnerset_controller.go +++ b/controllers/actions.github.com/autoscalingrunnerset_controller.go @@ -373,8 +373,8 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl } original := newOnce(listener.DeepCopy) - desiredLabels, listnerLabelsModified := r.mergeLabels(listener.Labels, desired.Labels) - if listnerLabelsModified { + desiredLabels, listenerLabelsModified := r.mergeLabels(listener.Labels, desired.Labels) + if listenerLabelsModified { original.Do() listener.Labels = desiredLabels } diff --git a/controllers/actions.github.com/utils.go b/controllers/actions.github.com/utils.go index 482c95d45f..0f5c651900 100644 --- a/controllers/actions.github.com/utils.go +++ b/controllers/actions.github.com/utils.go @@ -36,7 +36,7 @@ func (o *once[T]) Do() T { func (o *once[T]) Get() T { if !o.Called() { - panic("not done") + panic("once.Get called before Do") } return o.value }