diff --git a/internal/pkg/callbacks/rolling_upgrade.go b/internal/pkg/callbacks/rolling_upgrade.go index 13e5a63cd..e3592dc0d 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.Debugf("Rollout '%s/%s' uses workloadRef; defaulting to 'restart' strategy", + namespace, rollout.Name) + } + + logrus.Debugf("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 {