From c43bbdf4f2d2499624ca334617e06d717c5b799f Mon Sep 17 00:00:00 2001 From: edwardowens Date: Thu, 1 Oct 2026 16:27:32 -0700 Subject: [PATCH] fix(k8s): don't copy autoscalers or disruption budgets into bridges Bridging from source manifests copied every resource in them, including the service's HorizontalPodAutoscaler and PodDisruptionBudget. Those still act on the source workload after the bridge transforms (an HPA's scaleTargetRef and a PDB's selector name the real Deployment and pods), so each bridge broke the real service while it existed: its HPA reported AmbiguousSelector and stopped scaling, and its pods, covered by more than one PDB, couldn't be evicted. CreateBridge now strips HPAs, PDBs and KEDA ScaledObjects before anything else. A bridge is a single pod with nothing to scale. #12 already removes any that existing bridges created, on bridge remove. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/admin/local.go | 1 + pkg/k8s/resources/transforms.go | 29 +++++++++++ pkg/k8s/resources/transforms_test.go | 73 ++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/pkg/admin/local.go b/pkg/admin/local.go index 268a4c1..8e07b0c 100644 --- a/pkg/admin/local.go +++ b/pkg/admin/local.go @@ -144,6 +144,7 @@ func (l *adminService) CreateBridge(ctx context.Context, req *bridgev1.CreateBri // Transforms applied to all bridge deployments. transforms := []resources.Transformer{ + resources.StripScalingResources(), resources.SetNamespace(targetNS), resources.PruneAllMetadata(), resources.StripOrphanedVolumes(), diff --git a/pkg/k8s/resources/transforms.go b/pkg/k8s/resources/transforms.go index 57a2f5c..1b74712 100644 --- a/pkg/k8s/resources/transforms.go +++ b/pkg/k8s/resources/transforms.go @@ -8,9 +8,38 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/intstr" ) +// scalingGroupKinds are source kinds that act on the source workload rather +// than the bridge. An HPA's scaleTargetRef and a PDB's selector still name the +// real Deployment and its pods after the bridge transforms, so a copy breaks +// the real service: its HPA reports AmbiguousSelector and stops scaling, and +// its pods, covered by more than one PDB, can't be evicted. A bridge is a +// single pod with nothing to scale. +var scalingGroupKinds = map[schema.GroupKind]bool{ + {Group: "autoscaling", Kind: "HorizontalPodAutoscaler"}: true, + {Group: "policy", Kind: "PodDisruptionBudget"}: true, + {Group: "keda.sh", Kind: "ScaledObject"}: true, +} + +// StripScalingResources returns a Transformer that drops autoscalers and +// disruption budgets (scalingGroupKinds) from the bundle. +func StripScalingResources() Transformer { + return TransformFunc(func(_ *TransformContext, b *Bundle) error { + kept := b.Resources[:0] + for _, r := range b.Resources { + if scalingGroupKinds[r.GVK.GroupKind()] { + continue + } + kept = append(kept, r) + } + b.Resources = kept + return nil + }) +} + // SetNamespace returns a Transformer that sets .metadata.namespace on all resources. func SetNamespace(ns string) Transformer { return TransformFunc(func(_ *TransformContext, b *Bundle) error { diff --git a/pkg/k8s/resources/transforms_test.go b/pkg/k8s/resources/transforms_test.go index 9116232..2fbcda0 100644 --- a/pkg/k8s/resources/transforms_test.go +++ b/pkg/k8s/resources/transforms_test.go @@ -194,3 +194,76 @@ func TestInjectProxyImage_OmitsMountRootsWhenNoVolumes(t *testing.T) { assert.NotEqual(t, "--mount-roots", a, "should not pass --mount-roots when source has no VolumeMounts") } } + +// TestStripScalingResources verifies that autoscalers and disruption budgets +// from source manifests never reach the bridge: they would act on the source +// workload, not the bridge. +func TestStripScalingResources(t *testing.T) { + bundle, err := SourceFromManifests(packTestManifests(t, map[string]string{"manifests.yaml": `apiVersion: apps/v1 +kind: Deployment +metadata: + name: app +spec: + selector: + matchLabels: + app: app + template: + metadata: + labels: + app: app + spec: + containers: + - name: app + image: app:latest +--- +apiVersion: v1 +kind: Service +metadata: + name: app +spec: + selector: + app: app + ports: + - port: 80 +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: app +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: app + minReplicas: 2 + maxReplicas: 10 +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: app-pdb +spec: + maxUnavailable: 1 + selector: + matchLabels: + app: app +--- +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: app +spec: + scaleTargetRef: + name: app +`})) + require.NoError(t, err) + + require.Len(t, bundle.Resources, 5, "the source keeps all five") + require.NoError(t, StripScalingResources().Apply(&TransformContext{}, bundle)) + + var kinds []string + for _, r := range bundle.Resources { + kinds = append(kinds, r.GVK.Kind) + } + assert.ElementsMatch(t, []string{"Deployment", "Service"}, kinds) +}