From ec7e6818fd19a87a34beff3bf8b5b00b4d8d40f4 Mon Sep 17 00:00:00 2001 From: Alex Kazantcev Date: Thu, 2 Apr 2026 11:34:17 +0100 Subject: [PATCH 1/2] Support Argo Rollouts with workloadRef for restart strategy Rollouts using spec.workloadRef instead of an inline spec.template had empty pod templates, causing Reloader to skip them silently because container scanning found no ConfigMap/Secret references to watch. This change hydrates the Rollout's template from the referenced Deployment at scan time, enabling Reloader to discover resource references and patch spec.restartAt when changes are detected. Co-Authored-By: Claude Opus 4.6 --- internal/pkg/callbacks/rolling_upgrade.go | 58 +++- .../pkg/callbacks/rolling_upgrade_test.go | 277 ++++++++++++++++++ 2 files changed, 333 insertions(+), 2 deletions(-) diff --git a/internal/pkg/callbacks/rolling_upgrade.go b/internal/pkg/callbacks/rolling_upgrade.go index 13e5a63cd..3a7c7e44c 100644 --- a/internal/pkg/callbacks/rolling_upgrade.go +++ b/internal/pkg/callbacks/rolling_upgrade.go @@ -241,6 +241,8 @@ func GetRolloutItem(clients kube.Clients, name string, namespace string) (runtim return nil, err } + hydrateRolloutFromWorkloadRef(clients, rollout, namespace) + return rollout, nil } @@ -253,8 +255,9 @@ func GetRolloutItems(clients kube.Clients, namespace string) []runtime.Object { items := make([]runtime.Object, len(rollouts.Items)) // Ensure we always have pod annotations to add to - for i, v := range rollouts.Items { - if v.Spec.Template.Annotations == nil { + for i := range rollouts.Items { + hydrateRolloutFromWorkloadRef(clients, &rollouts.Items[i], namespace) + if rollouts.Items[i].Spec.Template.Annotations == nil { rollouts.Items[i].Spec.Template.Annotations = make(map[string]string) } items[i] = &rollouts.Items[i] @@ -263,6 +266,57 @@ func GetRolloutItems(clients kube.Clients, namespace string) []runtime.Object { return items } +// hydrateRolloutFromWorkloadRef populates a Rollout's spec.template from the +// referenced Deployment when spec.workloadRef is set. This allows Reloader's +// container-scanning logic to discover ConfigMap/Secret references even though +// the Rollout itself carries no pod template. +// Safety: forces "restart" strategy so that UpdateRollout only patches +// spec.restartAt and never writes the hydrated template back to the API. +func hydrateRolloutFromWorkloadRef(clients kube.Clients, rollout *argorolloutv1alpha1.Rollout, namespace string) { + workloadRef := rollout.Spec.WorkloadRef + if workloadRef == nil { + return + } + + // Only support Deployment references for now + if workloadRef.Kind != "Deployment" || workloadRef.APIVersion != "apps/v1" { + logrus.Debugf("Rollout '%s/%s' has unsupported workloadRef kind '%s/%s', skipping hydration", + namespace, rollout.Name, workloadRef.APIVersion, workloadRef.Kind) + return + } + + // Skip if template is already populated (inline template takes precedence) + if len(rollout.Spec.Template.Spec.Containers) > 0 { + return + } + + deployment, err := clients.KubernetesClient.AppsV1().Deployments(namespace).Get( + context.TODO(), workloadRef.Name, meta_v1.GetOptions{}) + if err != nil { + logrus.Errorf("Rollout '%s/%s': failed to fetch referenced Deployment '%s': %v", + namespace, rollout.Name, workloadRef.Name, err) + return + } + + // Copy the Deployment's pod template into the Rollout for container scanning + rollout.Spec.Template = *deployment.Spec.Template.DeepCopy() + rollout.Spec.TemplateResolvedFromRef = true + + // Force restart strategy to prevent UpdateRollout from writing the + // hydrated template back to the API server via .Update() + if rollout.Annotations == nil { + rollout.Annotations = make(map[string]string) + } + if _, hasStrategy := rollout.Annotations[options.RolloutStrategyAnnotation]; !hasStrategy { + rollout.Annotations[options.RolloutStrategyAnnotation] = "restart" + logrus.Infof("Rollout '%s/%s' uses workloadRef; defaulting to 'restart' strategy", + namespace, rollout.Name) + } + + logrus.Infof("Rollout '%s/%s': hydrated template from Deployment '%s' (%d containers)", + namespace, rollout.Name, workloadRef.Name, len(rollout.Spec.Template.Spec.Containers)) +} + // GetDeploymentAnnotations returns the annotations of given deployment func GetDeploymentAnnotations(item runtime.Object) map[string]string { if item.(*appsv1.Deployment).Annotations == nil { diff --git a/internal/pkg/callbacks/rolling_upgrade_test.go b/internal/pkg/callbacks/rolling_upgrade_test.go index 75583de45..4070889b3 100644 --- a/internal/pkg/callbacks/rolling_upgrade_test.go +++ b/internal/pkg/callbacks/rolling_upgrade_test.go @@ -491,6 +491,283 @@ func TestGetPatchDeleteTemplateEnvVar(t *testing.T) { assert.Equal(t, 2, strings.Count(templates.DeleteEnvVarTemplate, "%d")) } +// TestHydrateRolloutFromWorkloadRef tests that Rollouts with spec.workloadRef +// get their template hydrated from the referenced Deployment. +func TestHydrateRolloutFromWorkloadRef(t *testing.T) { + namespace := "test-hydrate-ns" + + // Create a Deployment that the Rollout's workloadRef will point to + deploymentContainers := []v1.Container{ + { + Name: "app", + Image: "myapp:latest", + EnvFrom: []v1.EnvFromSource{ + {ConfigMapRef: &v1.ConfigMapEnvSource{LocalObjectReference: v1.LocalObjectReference{Name: "my-configmap"}}}, + }, + }, + } + deploymentVolumes := []v1.Volume{ + { + Name: "secret-vol", + VolumeSource: v1.VolumeSource{ + Secret: &v1.SecretVolumeSource{SecretName: "my-secret"}, + }, + }, + } + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "referenced-deployment", + Namespace: namespace, + }, + Spec: appsv1.DeploymentSpec{ + Template: v1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{"existing-annotation": "value"}, + }, + Spec: v1.PodSpec{ + Containers: deploymentContainers, + Volumes: deploymentVolumes, + }, + }, + }, + } + _, err := clients.KubernetesClient.AppsV1().Deployments(namespace).Create( + context.TODO(), deployment, metav1.CreateOptions{}) + assert.NoError(t, err) + defer func() { + _ = clients.KubernetesClient.AppsV1().Deployments(namespace).Delete( + context.TODO(), "referenced-deployment", metav1.DeleteOptions{}) + }() + + t.Run("workloadRef Rollout gets template hydrated from Deployment", func(t *testing.T) { + // Create a Rollout with workloadRef and no inline template + rollout := &argorolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{ + Name: "workloadref-rollout", + Namespace: namespace, + Annotations: map[string]string{ + "reloader.stakater.com/auto": "true", + }, + }, + Spec: argorolloutv1alpha1.RolloutSpec{ + WorkloadRef: &argorolloutv1alpha1.ObjectRef{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: "referenced-deployment", + }, + }, + } + _, err := clients.ArgoRolloutClient.ArgoprojV1alpha1().Rollouts(namespace).Create( + context.TODO(), rollout, metav1.CreateOptions{}) + assert.NoError(t, err) + defer func() { + _ = clients.ArgoRolloutClient.ArgoprojV1alpha1().Rollouts(namespace).Delete( + context.TODO(), "workloadref-rollout", metav1.DeleteOptions{}) + }() + + // Fetch via GetRolloutItem — hydration should happen automatically + obj, err := callbacks.GetRolloutItem(clients, "workloadref-rollout", namespace) + assert.NoError(t, err) + + hydratedRollout := obj.(*argorolloutv1alpha1.Rollout) + + // Verify containers were hydrated from Deployment + assert.Len(t, hydratedRollout.Spec.Template.Spec.Containers, 1) + assert.Equal(t, "app", hydratedRollout.Spec.Template.Spec.Containers[0].Name) + + // Verify volumes were hydrated + assert.Len(t, hydratedRollout.Spec.Template.Spec.Volumes, 1) + assert.Equal(t, "my-secret", hydratedRollout.Spec.Template.Spec.Volumes[0].Secret.SecretName) + + // Verify restart strategy was defaulted for safety + assert.Equal(t, "restart", hydratedRollout.Annotations[options.RolloutStrategyAnnotation]) + + // Verify TemplateResolvedFromRef flag is set + assert.True(t, hydratedRollout.Spec.TemplateResolvedFromRef) + }) + + t.Run("workloadRef Rollout preserves explicit rollout-strategy annotation", func(t *testing.T) { + rollout := &argorolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{ + Name: "workloadref-explicit-strategy", + Namespace: namespace, + Annotations: map[string]string{ + options.RolloutStrategyAnnotation: "restart", + }, + }, + Spec: argorolloutv1alpha1.RolloutSpec{ + WorkloadRef: &argorolloutv1alpha1.ObjectRef{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: "referenced-deployment", + }, + }, + } + _, err := clients.ArgoRolloutClient.ArgoprojV1alpha1().Rollouts(namespace).Create( + context.TODO(), rollout, metav1.CreateOptions{}) + assert.NoError(t, err) + defer func() { + _ = clients.ArgoRolloutClient.ArgoprojV1alpha1().Rollouts(namespace).Delete( + context.TODO(), "workloadref-explicit-strategy", metav1.DeleteOptions{}) + }() + + obj, err := callbacks.GetRolloutItem(clients, "workloadref-explicit-strategy", namespace) + assert.NoError(t, err) + + hydratedRollout := obj.(*argorolloutv1alpha1.Rollout) + + // Existing annotation should not be overwritten + assert.Equal(t, "restart", hydratedRollout.Annotations[options.RolloutStrategyAnnotation]) + assert.Len(t, hydratedRollout.Spec.Template.Spec.Containers, 1) + }) + + t.Run("inline template Rollout is not hydrated", func(t *testing.T) { + // Rollout with both workloadRef and inline template — inline takes precedence + rollout := &argorolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{ + Name: "inline-template-rollout", + Namespace: namespace, + }, + Spec: argorolloutv1alpha1.RolloutSpec{ + WorkloadRef: &argorolloutv1alpha1.ObjectRef{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: "referenced-deployment", + }, + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{ + Containers: []v1.Container{{Name: "inline-container"}}, + }, + }, + }, + } + _, err := clients.ArgoRolloutClient.ArgoprojV1alpha1().Rollouts(namespace).Create( + context.TODO(), rollout, metav1.CreateOptions{}) + assert.NoError(t, err) + defer func() { + _ = clients.ArgoRolloutClient.ArgoprojV1alpha1().Rollouts(namespace).Delete( + context.TODO(), "inline-template-rollout", metav1.DeleteOptions{}) + }() + + obj, err := callbacks.GetRolloutItem(clients, "inline-template-rollout", namespace) + assert.NoError(t, err) + + fetchedRollout := obj.(*argorolloutv1alpha1.Rollout) + + // Should still have the inline container, not the Deployment's + assert.Len(t, fetchedRollout.Spec.Template.Spec.Containers, 1) + assert.Equal(t, "inline-container", fetchedRollout.Spec.Template.Spec.Containers[0].Name) + assert.False(t, fetchedRollout.Spec.TemplateResolvedFromRef) + }) + + t.Run("unsupported workloadRef kind is skipped", func(t *testing.T) { + rollout := &argorolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{ + Name: "unsupported-kind-rollout", + Namespace: namespace, + }, + Spec: argorolloutv1alpha1.RolloutSpec{ + WorkloadRef: &argorolloutv1alpha1.ObjectRef{ + APIVersion: "apps/v1", + Kind: "ReplicaSet", + Name: "some-replicaset", + }, + }, + } + _, err := clients.ArgoRolloutClient.ArgoprojV1alpha1().Rollouts(namespace).Create( + context.TODO(), rollout, metav1.CreateOptions{}) + assert.NoError(t, err) + defer func() { + _ = clients.ArgoRolloutClient.ArgoprojV1alpha1().Rollouts(namespace).Delete( + context.TODO(), "unsupported-kind-rollout", metav1.DeleteOptions{}) + }() + + obj, err := callbacks.GetRolloutItem(clients, "unsupported-kind-rollout", namespace) + assert.NoError(t, err) + + fetchedRollout := obj.(*argorolloutv1alpha1.Rollout) + + // Template should remain empty — no hydration for unsupported kinds + assert.Empty(t, fetchedRollout.Spec.Template.Spec.Containers) + assert.False(t, fetchedRollout.Spec.TemplateResolvedFromRef) + }) + + t.Run("missing referenced Deployment does not crash", func(t *testing.T) { + rollout := &argorolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{ + Name: "missing-deployment-rollout", + Namespace: namespace, + }, + Spec: argorolloutv1alpha1.RolloutSpec{ + WorkloadRef: &argorolloutv1alpha1.ObjectRef{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: "nonexistent-deployment", + }, + }, + } + _, err := clients.ArgoRolloutClient.ArgoprojV1alpha1().Rollouts(namespace).Create( + context.TODO(), rollout, metav1.CreateOptions{}) + assert.NoError(t, err) + defer func() { + _ = clients.ArgoRolloutClient.ArgoprojV1alpha1().Rollouts(namespace).Delete( + context.TODO(), "missing-deployment-rollout", metav1.DeleteOptions{}) + }() + + obj, err := callbacks.GetRolloutItem(clients, "missing-deployment-rollout", namespace) + assert.NoError(t, err) + + fetchedRollout := obj.(*argorolloutv1alpha1.Rollout) + + // Should gracefully return empty template, not error + assert.Empty(t, fetchedRollout.Spec.Template.Spec.Containers) + assert.False(t, fetchedRollout.Spec.TemplateResolvedFromRef) + }) + + t.Run("GetRolloutItems hydrates workloadRef Rollouts in list", func(t *testing.T) { + rollout := &argorolloutv1alpha1.Rollout{ + ObjectMeta: metav1.ObjectMeta{ + Name: "list-workloadref-rollout", + Namespace: namespace, + Annotations: map[string]string{ + "reloader.stakater.com/auto": "true", + }, + }, + Spec: argorolloutv1alpha1.RolloutSpec{ + WorkloadRef: &argorolloutv1alpha1.ObjectRef{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: "referenced-deployment", + }, + }, + } + _, err := clients.ArgoRolloutClient.ArgoprojV1alpha1().Rollouts(namespace).Create( + context.TODO(), rollout, metav1.CreateOptions{}) + assert.NoError(t, err) + defer func() { + _ = clients.ArgoRolloutClient.ArgoprojV1alpha1().Rollouts(namespace).Delete( + context.TODO(), "list-workloadref-rollout", metav1.DeleteOptions{}) + }() + + items := callbacks.GetRolloutItems(clients, namespace) + assert.NotEmpty(t, items) + + // Find our rollout in the list + var foundRollout *argorolloutv1alpha1.Rollout + for _, item := range items { + r := item.(*argorolloutv1alpha1.Rollout) + if r.Name == "list-workloadref-rollout" { + foundRollout = r + break + } + } + assert.NotNil(t, foundRollout, "workloadRef rollout should be in items list") + assert.Len(t, foundRollout.Spec.Template.Spec.Containers, 1) + assert.Equal(t, "app", foundRollout.Spec.Template.Spec.Containers[0].Name) + assert.True(t, foundRollout.Spec.TemplateResolvedFromRef) + }) +} + // Helper functions func isRestartStrategy(rollout *argorolloutv1alpha1.Rollout) bool { From 3def28c14cc52da5bd303efa5407940fb1a784d0 Mon Sep 17 00:00:00 2001 From: Alex Kazantcev Date: Thu, 2 Apr 2026 12:10:16 +0100 Subject: [PATCH 2/2] Reduce workloadRef hydration log verbosity to debug level Hydration runs on every polling cycle (~30s), making Info-level logs too noisy in production. Debug level is appropriate for routine per-cycle operations. Co-Authored-By: Claude Opus 4.6 --- internal/pkg/callbacks/rolling_upgrade.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/pkg/callbacks/rolling_upgrade.go b/internal/pkg/callbacks/rolling_upgrade.go index 3a7c7e44c..e3592dc0d 100644 --- a/internal/pkg/callbacks/rolling_upgrade.go +++ b/internal/pkg/callbacks/rolling_upgrade.go @@ -309,11 +309,11 @@ func hydrateRolloutFromWorkloadRef(clients kube.Clients, rollout *argorolloutv1a } if _, hasStrategy := rollout.Annotations[options.RolloutStrategyAnnotation]; !hasStrategy { rollout.Annotations[options.RolloutStrategyAnnotation] = "restart" - logrus.Infof("Rollout '%s/%s' uses workloadRef; defaulting to 'restart' strategy", + logrus.Debugf("Rollout '%s/%s' uses workloadRef; defaulting to 'restart' strategy", namespace, rollout.Name) } - logrus.Infof("Rollout '%s/%s': hydrated template from Deployment '%s' (%d containers)", + logrus.Debugf("Rollout '%s/%s': hydrated template from Deployment '%s' (%d containers)", namespace, rollout.Name, workloadRef.Name, len(rollout.Spec.Template.Spec.Containers)) }