From e209d803451ea9321a876fe5fa72718c06d9c21a Mon Sep 17 00:00:00 2001 From: edwardowens Date: Wed, 30 Sep 2026 12:48:57 -0700 Subject: [PATCH 1/4] feat(k8s): bridge a CronJob as a Deployment A CronJob can now be a bridge's source. It's bridged as a single-replica Deployment running the CronJob's pod template, so the bridge gets the job's service account, env and volumes, but not its schedule; run the job in the devcontainer with `bridge exec`. restartPolicy becomes Always and activeDeadlineSeconds is dropped, since a Deployment's pods allow neither. - --source manifests with no Deployment bridge their CronJob (the first by name, if there are several). It isn't also saved as a CronJob. Manifests that have a Deployment are unchanged. - `bridge create ` falls back to a CronJob of that name when there's no Deployment. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/commands/create.go | 5 + pkg/k8s/resources/cronjob.go | 39 ++++++ pkg/k8s/resources/cronjob_test.go | 178 ++++++++++++++++++++++++++ pkg/k8s/resources/source.go | 12 +- pkg/k8s/resources/source_manifests.go | 38 +++++- 5 files changed, 269 insertions(+), 3 deletions(-) create mode 100644 pkg/k8s/resources/cronjob.go create mode 100644 pkg/k8s/resources/cronjob_test.go diff --git a/pkg/commands/create.go b/pkg/commands/create.go index a482d43..1dcb294 100644 --- a/pkg/commands/create.go +++ b/pkg/commands/create.go @@ -50,6 +50,11 @@ Examples: # Create from a directory of Kubernetes manifests bridge create --source ./k8s/ + # A CronJob works as a source too: the bridge runs its pod template (service + # account, env, volumes) without its schedule. Run the job with bridge exec. + bridge create my-cron-job + bridge exec my-cron-job -- node dist/index.js + # Create then run a command with bridge exec bridge create my-api bridge exec my-api -- npm test diff --git a/pkg/k8s/resources/cronjob.go b/pkg/k8s/resources/cronjob.go new file mode 100644 index 0000000..2e70b50 --- /dev/null +++ b/pkg/k8s/resources/cronjob.go @@ -0,0 +1,39 @@ +package resources + +import ( + "maps" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// DeploymentFromCronJob returns a single-replica Deployment that runs the +// CronJob's pod template, so a CronJob can be bridged like a Deployment. The +// bridge gets the job's service account, environment and volumes, but not its +// schedule: run the job in the devcontainer with `bridge exec`. +func DeploymentFromCronJob(cj *batchv1.CronJob) *appsv1.Deployment { + template := *cj.Spec.JobTemplate.Spec.Template.DeepCopy() + // A Deployment's pods must restart Always and can't set a deadline. + template.Spec.RestartPolicy = corev1.RestartPolicyAlways + template.Spec.ActiveDeadlineSeconds = nil + if len(template.Labels) == 0 { + template.Labels = map[string]string{"app": cj.Name} + } + + replicas := int32(1) + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: cj.Name, + Namespace: cj.Namespace, + Labels: maps.Clone(cj.Labels), + Annotations: maps.Clone(cj.Annotations), + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{MatchLabels: maps.Clone(template.Labels)}, + Template: template, + }, + } +} diff --git a/pkg/k8s/resources/cronjob_test.go b/pkg/k8s/resources/cronjob_test.go new file mode 100644 index 0000000..3319282 --- /dev/null +++ b/pkg/k8s/resources/cronjob_test.go @@ -0,0 +1,178 @@ +package resources + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8stesting "k8s.io/client-go/kubernetes/fake" +) + +const testCronJobManifest = `apiVersion: batch/v1 +kind: CronJob +metadata: + name: my-cron + labels: + team: platform +spec: + schedule: "0 * * * *" + jobTemplate: + spec: + template: + metadata: + labels: + app: my-cron + spec: + serviceAccountName: my-cron-sa + restartPolicy: Never + activeDeadlineSeconds: 600 + containers: + - name: job + image: my-cron:latest + env: + - name: FOO + value: bar +` + +func TestDeploymentFromCronJob(t *testing.T) { + deadline := int64(600) + cj := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-cron", + Namespace: "jobs", + Labels: map[string]string{"team": "platform"}, + Annotations: map[string]string{"owner": "me"}, + }, + Spec: batchv1.CronJobSpec{ + Schedule: "0 * * * *", + JobTemplate: batchv1.JobTemplateSpec{Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "my-cron"}}, + Spec: corev1.PodSpec{ + ServiceAccountName: "my-cron-sa", + RestartPolicy: corev1.RestartPolicyOnFailure, + ActiveDeadlineSeconds: &deadline, + Containers: []corev1.Container{{Name: "job", Image: "my-cron:latest"}}, + }, + }}}, + }, + } + + deploy := DeploymentFromCronJob(cj) + + assert.Equal(t, "my-cron", deploy.Name) + assert.Equal(t, "jobs", deploy.Namespace) + assert.Equal(t, map[string]string{"team": "platform"}, deploy.Labels) + assert.Equal(t, map[string]string{"owner": "me"}, deploy.Annotations) + require.NotNil(t, deploy.Spec.Replicas) + assert.Equal(t, int32(1), *deploy.Spec.Replicas) + assert.Equal(t, map[string]string{"app": "my-cron"}, deploy.Spec.Selector.MatchLabels) + + pod := deploy.Spec.Template.Spec + assert.Equal(t, "my-cron-sa", pod.ServiceAccountName) + assert.Equal(t, corev1.RestartPolicyAlways, pod.RestartPolicy) + assert.Nil(t, pod.ActiveDeadlineSeconds) + + // The CronJob itself is left untouched. + assert.Equal(t, corev1.RestartPolicyOnFailure, cj.Spec.JobTemplate.Spec.Template.Spec.RestartPolicy) + assert.NotNil(t, cj.Spec.JobTemplate.Spec.Template.Spec.ActiveDeadlineSeconds) +} + +func TestDeploymentFromCronJob_UnlabeledTemplate(t *testing.T) { + cj := &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{Name: "my-cron"}} + + deploy := DeploymentFromCronJob(cj) + + assert.Equal(t, map[string]string{"app": "my-cron"}, deploy.Spec.Template.Labels) + assert.Equal(t, map[string]string{"app": "my-cron"}, deploy.Spec.Selector.MatchLabels) +} + +func TestCreateFromManifests_CronJob(t *testing.T) { + manifests := packTestManifests(t, map[string]string{"cron.yaml": testCronJobManifest}) + + client := k8stesting.NewSimpleClientset() + deployName, bundle, err := testCreateFromManifests(t, client, manifests, "default") + require.NoError(t, err) + assert.Equal(t, testDeployName("my-cron"), deployName) + + for _, r := range bundle.Resources { + _, isCronJob := r.Object.(*batchv1.CronJob) + assert.False(t, isCronJob, "the bridged CronJob must not also be saved as a CronJob") + } + + deploy, err := client.AppsV1().Deployments("default").Get(context.Background(), deployName, metav1.GetOptions{}) + require.NoError(t, err) + pod := deploy.Spec.Template.Spec + assert.Equal(t, "my-cron-sa", pod.ServiceAccountName) + assert.Equal(t, corev1.RestartPolicyAlways, pod.RestartPolicy) + assert.Nil(t, pod.ActiveDeadlineSeconds) + assert.Contains(t, pod.Containers[0].Env, corev1.EnvVar{Name: "FOO", Value: "bar"}) +} + +func TestSourceFromManifests_PrefersDeploymentToCronJob(t *testing.T) { + manifests := packTestManifests(t, map[string]string{ + "cron.yaml": testCronJobManifest, + "deploy.yaml": `apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-app +spec: + selector: + matchLabels: + app: my-app + template: + metadata: + labels: + app: my-app + spec: + containers: + - name: app + image: my-app:latest +`, + }) + + bundle, err := SourceFromManifests(manifests) + require.NoError(t, err) + assert.Equal(t, "my-app", FindDeploymentName(bundle)) + + var deployments, cronJobs int + for _, r := range bundle.Resources { + switch r.Object.(type) { + case *appsv1.Deployment: + deployments++ + case *batchv1.CronJob: + cronJobs++ + } + } + assert.Equal(t, 1, deployments) + assert.Equal(t, 1, cronJobs, "a CronJob next to a Deployment is kept as it is") +} + +func TestSourceFromNamespace_CronJob(t *testing.T) { + client := k8stesting.NewSimpleClientset(&batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{Name: "my-cron", Namespace: "default"}, + Spec: batchv1.CronJobSpec{JobTemplate: batchv1.JobTemplateSpec{Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{ServiceAccountName: "my-cron-sa"}}, + }}}, + }) + + bundle, err := SourceFromNamespace(context.Background(), client, "default", "my-cron") + require.NoError(t, err) + require.Len(t, bundle.Resources, 1) + deploy, ok := bundle.Resources[0].Object.(*appsv1.Deployment) + require.True(t, ok) + assert.Equal(t, "my-cron", deploy.Name) + assert.Equal(t, "my-cron-sa", deploy.Spec.Template.Spec.ServiceAccountName) +} + +func TestSourceFromNamespace_NeitherFound(t *testing.T) { + _, err := SourceFromNamespace(context.Background(), k8stesting.NewSimpleClientset(), "default", "missing") + + var notFound *DeploymentNotFoundError + assert.ErrorAs(t, err, ¬Found) +} diff --git a/pkg/k8s/resources/source.go b/pkg/k8s/resources/source.go index be0f0d7..fb5f76a 100644 --- a/pkg/k8s/resources/source.go +++ b/pkg/k8s/resources/source.go @@ -14,9 +14,19 @@ import ( // SourceFromNamespace fetches a source deployment from the cluster and returns // a Bundle containing just that deployment. Only the deployment spec is used — // not the live pod — so that webhook-injected env vars and volume mounts (e.g. -// IRSA) are absent and get cleanly re-injected on the bridge pod. +// IRSA) are absent and get cleanly re-injected on the bridge pod. With no +// Deployment of that name, a CronJob of that name is bridged as a Deployment +// (see DeploymentFromCronJob). func SourceFromNamespace(ctx context.Context, client kubernetes.Interface, namespace, deployment string) (*Bundle, error) { srcDeploy, err := client.AppsV1().Deployments(namespace).Get(ctx, deployment, metav1.GetOptions{}) + if errors.IsNotFound(err) { + cronJob, cjErr := client.BatchV1().CronJobs(namespace).Get(ctx, deployment, metav1.GetOptions{}) + if cjErr == nil { + srcDeploy, err = DeploymentFromCronJob(cronJob), nil + } else if !errors.IsNotFound(cjErr) { + return nil, fmt.Errorf("failed to get source cronjob %s/%s: %w", namespace, deployment, cjErr) + } + } if err != nil { if errors.IsNotFound(err) { return nil, &DeploymentNotFoundError{Name: deployment, Namespace: namespace} diff --git a/pkg/k8s/resources/source_manifests.go b/pkg/k8s/resources/source_manifests.go index 63c2fa5..e9f9c14 100644 --- a/pkg/k8s/resources/source_manifests.go +++ b/pkg/k8s/resources/source_manifests.go @@ -6,11 +6,13 @@ import ( "fmt" "io" "log/slog" + "slices" "strings" "github.com/vercel/bridge/pkg/archive" appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" @@ -20,7 +22,8 @@ import ( // SourceFromManifests unpacks a tar.gz archive of Kubernetes YAML files, parses // every document into typed or unstructured resources, and returns a Bundle -// ready for the transform pipeline. +// ready for the transform pipeline. Manifests with no Deployment but a CronJob +// bridge the CronJob, as a Deployment (see DeploymentFromCronJob). func SourceFromManifests(manifests []byte) (*Bundle, error) { fileMap, err := archive.Unpack(manifests) if err != nil { @@ -28,6 +31,8 @@ func SourceFromManifests(manifests []byte) (*Bundle, error) { } var resources []Resource + var cronJobs []*batchv1.CronJob + hasDeployment := false decoder := scheme.Codecs.UniversalDeserializer() @@ -50,9 +55,12 @@ func SourceFromManifests(manifests []byte) (*Bundle, error) { continue } - switch obj.(type) { + switch o := obj.(type) { case *appsv1.Deployment: + hasDeployment = true resources = append(resources, Resource{Object: obj, GVK: *gvk}) + case *batchv1.CronJob: + cronJobs = append(cronJobs, o) case *corev1.Service, *corev1.ConfigMap, *corev1.Secret, *corev1.ServiceAccount: resources = append(resources, Resource{Object: obj, GVK: *gvk}) default: @@ -65,11 +73,37 @@ func SourceFromManifests(manifests []byte) (*Bundle, error) { } } + resources = append(resources, cronJobResources(cronJobs, hasDeployment)...) + return &Bundle{ Resources: resources, }, nil } +// cronJobResources returns the bundle's CronJobs as resources. With no +// Deployment to bridge, the first CronJob (by name) becomes the workload, as a +// Deployment; the rest are kept as they are, like any other resource. +func cronJobResources(cronJobs []*batchv1.CronJob, hasDeployment bool) []Resource { + slices.SortFunc(cronJobs, func(a, b *batchv1.CronJob) int { + return strings.Compare(a.Name, b.Name) + }) + var out []Resource + for i, cj := range cronJobs { + if i == 0 && !hasDeployment { + if len(cronJobs) > 1 { + slog.Warn("Bridging the first of several CronJobs", "cronjob", cj.Name, "count", len(cronJobs)) + } + out = append(out, Resource{ + Object: DeploymentFromCronJob(cj), + GVK: appsv1.SchemeGroupVersion.WithKind("Deployment"), + }) + continue + } + out = append(out, Resource{Object: cj, GVK: batchv1.SchemeGroupVersion.WithKind("CronJob")}) + } + return out +} + // splitYAMLDocuments splits multi-document YAML (separated by ---) into individual documents. func splitYAMLDocuments(data []byte) [][]byte { reader := yaml.NewYAMLReader(bufio.NewReader(bytes.NewReader(data))) From 956b8a5d514ad5deabbd6b47d420b7087926c69f Mon Sep 17 00:00:00 2001 From: edwardowens Date: Wed, 30 Sep 2026 13:04:07 -0700 Subject: [PATCH 2/4] refactor(k8s): bridge any workload kind as a Deployment Replaces the CronJob-specific conversion with one that works for every workload kind: take the workload's pod template (spec.template, or spec.jobTemplate.spec.template for a CronJob) and its metadata, and build a single-replica Deployment from them. The rest of the pipeline only ever sees that Deployment. - --source manifests: every document with a pod template becomes a Deployment, ordered by kind (Deployment, StatefulSet, DaemonSet, ReplicaSet, Job, CronJob), so a Deployment is the source whenever the manifests have one. A converted workload isn't also saved as itself. - `bridge create ` looks the name up with the dynamic client across those kinds, in that order, and returns WorkloadNotFoundError (NotFound over gRPC) when none has it. - Drops the CronJob example from `bridge create --help`. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/admin/local.go | 2 +- pkg/commands/administrator.go | 3 +- pkg/commands/create.go | 5 - pkg/k8s/resources/cronjob.go | 39 ----- pkg/k8s/resources/cronjob_test.go | 178 --------------------- pkg/k8s/resources/resources.go | 11 ++ pkg/k8s/resources/source.go | 52 +++---- pkg/k8s/resources/source_manifests.go | 59 +++---- pkg/k8s/resources/workload.go | 97 ++++++++++++ pkg/k8s/resources/workload_test.go | 216 ++++++++++++++++++++++++++ 10 files changed, 376 insertions(+), 286 deletions(-) delete mode 100644 pkg/k8s/resources/cronjob.go delete mode 100644 pkg/k8s/resources/cronjob_test.go create mode 100644 pkg/k8s/resources/workload.go create mode 100644 pkg/k8s/resources/workload_test.go diff --git a/pkg/admin/local.go b/pkg/admin/local.go index cf429e0..268a4c1 100644 --- a/pkg/admin/local.go +++ b/pkg/admin/local.go @@ -117,7 +117,7 @@ func (l *adminService) CreateBridge(ctx context.Context, req *bridgev1.CreateBri bundle, err = resources.SourceFromManifests(req.SourceManifests) } else if req.SourceDeployment != "" { logger.Info("Creating bridge") - bundle, err = resources.SourceFromNamespace(ctx, l.client, targetNS, req.SourceDeployment) + bundle, err = resources.SourceFromNamespace(ctx, l.dynClient, targetNS, req.SourceDeployment) } else { logger.Info("Creating simple bridge") bundle, err = resources.SourceSimple(targetNS, proxyImage) diff --git a/pkg/commands/administrator.go b/pkg/commands/administrator.go index a53cbd0..a61166c 100644 --- a/pkg/commands/administrator.go +++ b/pkg/commands/administrator.go @@ -203,7 +203,8 @@ func (s *administratorServer) DeleteBridge(ctx context.Context, req *bridgev1.De // gRPC status error. func grpcError(err error) error { var notFound *resources.DeploymentNotFoundError - if errors.As(err, ¬Found) { + var workloadNotFound *resources.WorkloadNotFoundError + if errors.As(err, ¬Found) || errors.As(err, &workloadNotFound) { return status.Error(codes.NotFound, err.Error()) } return status.Error(codes.Internal, err.Error()) diff --git a/pkg/commands/create.go b/pkg/commands/create.go index 1dcb294..a482d43 100644 --- a/pkg/commands/create.go +++ b/pkg/commands/create.go @@ -50,11 +50,6 @@ Examples: # Create from a directory of Kubernetes manifests bridge create --source ./k8s/ - # A CronJob works as a source too: the bridge runs its pod template (service - # account, env, volumes) without its schedule. Run the job with bridge exec. - bridge create my-cron-job - bridge exec my-cron-job -- node dist/index.js - # Create then run a command with bridge exec bridge create my-api bridge exec my-api -- npm test diff --git a/pkg/k8s/resources/cronjob.go b/pkg/k8s/resources/cronjob.go deleted file mode 100644 index 2e70b50..0000000 --- a/pkg/k8s/resources/cronjob.go +++ /dev/null @@ -1,39 +0,0 @@ -package resources - -import ( - "maps" - - appsv1 "k8s.io/api/apps/v1" - batchv1 "k8s.io/api/batch/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// DeploymentFromCronJob returns a single-replica Deployment that runs the -// CronJob's pod template, so a CronJob can be bridged like a Deployment. The -// bridge gets the job's service account, environment and volumes, but not its -// schedule: run the job in the devcontainer with `bridge exec`. -func DeploymentFromCronJob(cj *batchv1.CronJob) *appsv1.Deployment { - template := *cj.Spec.JobTemplate.Spec.Template.DeepCopy() - // A Deployment's pods must restart Always and can't set a deadline. - template.Spec.RestartPolicy = corev1.RestartPolicyAlways - template.Spec.ActiveDeadlineSeconds = nil - if len(template.Labels) == 0 { - template.Labels = map[string]string{"app": cj.Name} - } - - replicas := int32(1) - return &appsv1.Deployment{ - ObjectMeta: metav1.ObjectMeta{ - Name: cj.Name, - Namespace: cj.Namespace, - Labels: maps.Clone(cj.Labels), - Annotations: maps.Clone(cj.Annotations), - }, - Spec: appsv1.DeploymentSpec{ - Replicas: &replicas, - Selector: &metav1.LabelSelector{MatchLabels: maps.Clone(template.Labels)}, - Template: template, - }, - } -} diff --git a/pkg/k8s/resources/cronjob_test.go b/pkg/k8s/resources/cronjob_test.go deleted file mode 100644 index 3319282..0000000 --- a/pkg/k8s/resources/cronjob_test.go +++ /dev/null @@ -1,178 +0,0 @@ -package resources - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - appsv1 "k8s.io/api/apps/v1" - batchv1 "k8s.io/api/batch/v1" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - k8stesting "k8s.io/client-go/kubernetes/fake" -) - -const testCronJobManifest = `apiVersion: batch/v1 -kind: CronJob -metadata: - name: my-cron - labels: - team: platform -spec: - schedule: "0 * * * *" - jobTemplate: - spec: - template: - metadata: - labels: - app: my-cron - spec: - serviceAccountName: my-cron-sa - restartPolicy: Never - activeDeadlineSeconds: 600 - containers: - - name: job - image: my-cron:latest - env: - - name: FOO - value: bar -` - -func TestDeploymentFromCronJob(t *testing.T) { - deadline := int64(600) - cj := &batchv1.CronJob{ - ObjectMeta: metav1.ObjectMeta{ - Name: "my-cron", - Namespace: "jobs", - Labels: map[string]string{"team": "platform"}, - Annotations: map[string]string{"owner": "me"}, - }, - Spec: batchv1.CronJobSpec{ - Schedule: "0 * * * *", - JobTemplate: batchv1.JobTemplateSpec{Spec: batchv1.JobSpec{Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "my-cron"}}, - Spec: corev1.PodSpec{ - ServiceAccountName: "my-cron-sa", - RestartPolicy: corev1.RestartPolicyOnFailure, - ActiveDeadlineSeconds: &deadline, - Containers: []corev1.Container{{Name: "job", Image: "my-cron:latest"}}, - }, - }}}, - }, - } - - deploy := DeploymentFromCronJob(cj) - - assert.Equal(t, "my-cron", deploy.Name) - assert.Equal(t, "jobs", deploy.Namespace) - assert.Equal(t, map[string]string{"team": "platform"}, deploy.Labels) - assert.Equal(t, map[string]string{"owner": "me"}, deploy.Annotations) - require.NotNil(t, deploy.Spec.Replicas) - assert.Equal(t, int32(1), *deploy.Spec.Replicas) - assert.Equal(t, map[string]string{"app": "my-cron"}, deploy.Spec.Selector.MatchLabels) - - pod := deploy.Spec.Template.Spec - assert.Equal(t, "my-cron-sa", pod.ServiceAccountName) - assert.Equal(t, corev1.RestartPolicyAlways, pod.RestartPolicy) - assert.Nil(t, pod.ActiveDeadlineSeconds) - - // The CronJob itself is left untouched. - assert.Equal(t, corev1.RestartPolicyOnFailure, cj.Spec.JobTemplate.Spec.Template.Spec.RestartPolicy) - assert.NotNil(t, cj.Spec.JobTemplate.Spec.Template.Spec.ActiveDeadlineSeconds) -} - -func TestDeploymentFromCronJob_UnlabeledTemplate(t *testing.T) { - cj := &batchv1.CronJob{ObjectMeta: metav1.ObjectMeta{Name: "my-cron"}} - - deploy := DeploymentFromCronJob(cj) - - assert.Equal(t, map[string]string{"app": "my-cron"}, deploy.Spec.Template.Labels) - assert.Equal(t, map[string]string{"app": "my-cron"}, deploy.Spec.Selector.MatchLabels) -} - -func TestCreateFromManifests_CronJob(t *testing.T) { - manifests := packTestManifests(t, map[string]string{"cron.yaml": testCronJobManifest}) - - client := k8stesting.NewSimpleClientset() - deployName, bundle, err := testCreateFromManifests(t, client, manifests, "default") - require.NoError(t, err) - assert.Equal(t, testDeployName("my-cron"), deployName) - - for _, r := range bundle.Resources { - _, isCronJob := r.Object.(*batchv1.CronJob) - assert.False(t, isCronJob, "the bridged CronJob must not also be saved as a CronJob") - } - - deploy, err := client.AppsV1().Deployments("default").Get(context.Background(), deployName, metav1.GetOptions{}) - require.NoError(t, err) - pod := deploy.Spec.Template.Spec - assert.Equal(t, "my-cron-sa", pod.ServiceAccountName) - assert.Equal(t, corev1.RestartPolicyAlways, pod.RestartPolicy) - assert.Nil(t, pod.ActiveDeadlineSeconds) - assert.Contains(t, pod.Containers[0].Env, corev1.EnvVar{Name: "FOO", Value: "bar"}) -} - -func TestSourceFromManifests_PrefersDeploymentToCronJob(t *testing.T) { - manifests := packTestManifests(t, map[string]string{ - "cron.yaml": testCronJobManifest, - "deploy.yaml": `apiVersion: apps/v1 -kind: Deployment -metadata: - name: my-app -spec: - selector: - matchLabels: - app: my-app - template: - metadata: - labels: - app: my-app - spec: - containers: - - name: app - image: my-app:latest -`, - }) - - bundle, err := SourceFromManifests(manifests) - require.NoError(t, err) - assert.Equal(t, "my-app", FindDeploymentName(bundle)) - - var deployments, cronJobs int - for _, r := range bundle.Resources { - switch r.Object.(type) { - case *appsv1.Deployment: - deployments++ - case *batchv1.CronJob: - cronJobs++ - } - } - assert.Equal(t, 1, deployments) - assert.Equal(t, 1, cronJobs, "a CronJob next to a Deployment is kept as it is") -} - -func TestSourceFromNamespace_CronJob(t *testing.T) { - client := k8stesting.NewSimpleClientset(&batchv1.CronJob{ - ObjectMeta: metav1.ObjectMeta{Name: "my-cron", Namespace: "default"}, - Spec: batchv1.CronJobSpec{JobTemplate: batchv1.JobTemplateSpec{Spec: batchv1.JobSpec{ - Template: corev1.PodTemplateSpec{Spec: corev1.PodSpec{ServiceAccountName: "my-cron-sa"}}, - }}}, - }) - - bundle, err := SourceFromNamespace(context.Background(), client, "default", "my-cron") - require.NoError(t, err) - require.Len(t, bundle.Resources, 1) - deploy, ok := bundle.Resources[0].Object.(*appsv1.Deployment) - require.True(t, ok) - assert.Equal(t, "my-cron", deploy.Name) - assert.Equal(t, "my-cron-sa", deploy.Spec.Template.Spec.ServiceAccountName) -} - -func TestSourceFromNamespace_NeitherFound(t *testing.T) { - _, err := SourceFromNamespace(context.Background(), k8stesting.NewSimpleClientset(), "default", "missing") - - var notFound *DeploymentNotFoundError - assert.ErrorAs(t, err, ¬Found) -} diff --git a/pkg/k8s/resources/resources.go b/pkg/k8s/resources/resources.go index b50ce3b..2ee5fa3 100644 --- a/pkg/k8s/resources/resources.go +++ b/pkg/k8s/resources/resources.go @@ -32,6 +32,17 @@ const ( DefaultWorkloadKind = "Deployment" ) +// WorkloadNotFoundError is returned when no workload of any supported kind has +// the source name. +type WorkloadNotFoundError struct { + Name string + Namespace string +} + +func (e *WorkloadNotFoundError) Error() string { + return fmt.Sprintf("no workload found named '%s' in namespace '%s'", e.Name, e.Namespace) +} + // DeploymentNotFoundError is returned when the source deployment does not exist. type DeploymentNotFoundError struct { Name string diff --git a/pkg/k8s/resources/source.go b/pkg/k8s/resources/source.go index fb5f76a..6cae4b1 100644 --- a/pkg/k8s/resources/source.go +++ b/pkg/k8s/resources/source.go @@ -8,37 +8,37 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes" + "k8s.io/client-go/dynamic" ) -// SourceFromNamespace fetches a source deployment from the cluster and returns -// a Bundle containing just that deployment. Only the deployment spec is used — -// not the live pod — so that webhook-injected env vars and volume mounts (e.g. -// IRSA) are absent and get cleanly re-injected on the bridge pod. With no -// Deployment of that name, a CronJob of that name is bridged as a Deployment -// (see DeploymentFromCronJob). -func SourceFromNamespace(ctx context.Context, client kubernetes.Interface, namespace, deployment string) (*Bundle, error) { - srcDeploy, err := client.AppsV1().Deployments(namespace).Get(ctx, deployment, metav1.GetOptions{}) - if errors.IsNotFound(err) { - cronJob, cjErr := client.BatchV1().CronJobs(namespace).Get(ctx, deployment, metav1.GetOptions{}) - if cjErr == nil { - srcDeploy, err = DeploymentFromCronJob(cronJob), nil - } else if !errors.IsNotFound(cjErr) { - return nil, fmt.Errorf("failed to get source cronjob %s/%s: %w", namespace, deployment, cjErr) - } - } - if err != nil { +// SourceFromNamespace fetches the named workload from the cluster, trying each +// of workloadKinds in order, and returns a Bundle holding it as a Deployment +// (see DeploymentFromWorkload). Only the workload's pod template is used — not a +// live pod — so that webhook-injected env vars and volume mounts (e.g. IRSA) +// are absent and get cleanly re-injected on the bridge pod. +func SourceFromNamespace(ctx context.Context, client dynamic.Interface, namespace, name string) (*Bundle, error) { + for _, kind := range workloadKinds { + obj, err := client.Resource(gvkToGVR(kind)).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) if errors.IsNotFound(err) { - return nil, &DeploymentNotFoundError{Name: deployment, Namespace: namespace} + continue + } + if err != nil { + return nil, fmt.Errorf("failed to get source %s %s/%s: %w", kind.Kind, namespace, name, err) } - return nil, fmt.Errorf("failed to get source deployment %s/%s: %w", namespace, deployment, err) + deploy, err := DeploymentFromWorkload(obj) + if err != nil { + return nil, err + } + if deploy == nil { + return nil, fmt.Errorf("%s %s/%s has no pod template", kind.Kind, namespace, name) + } + return &Bundle{ + Resources: []Resource{ + {Object: deploy, GVK: appsv1.SchemeGroupVersion.WithKind("Deployment")}, + }, + }, nil } - - return &Bundle{ - Resources: []Resource{ - {Object: srcDeploy, GVK: appsv1.SchemeGroupVersion.WithKind("Deployment")}, - }, - }, nil + return nil, &WorkloadNotFoundError{Name: name, Namespace: namespace} } // SourceSimple builds a minimal Deployment with just the bridge proxy container. diff --git a/pkg/k8s/resources/source_manifests.go b/pkg/k8s/resources/source_manifests.go index e9f9c14..c28625d 100644 --- a/pkg/k8s/resources/source_manifests.go +++ b/pkg/k8s/resources/source_manifests.go @@ -12,7 +12,6 @@ import ( "github.com/vercel/bridge/pkg/archive" appsv1 "k8s.io/api/apps/v1" - batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" @@ -22,8 +21,8 @@ import ( // SourceFromManifests unpacks a tar.gz archive of Kubernetes YAML files, parses // every document into typed or unstructured resources, and returns a Bundle -// ready for the transform pipeline. Manifests with no Deployment but a CronJob -// bridge the CronJob, as a Deployment (see DeploymentFromCronJob). +// ready for the transform pipeline. Every workload, whatever its kind, becomes a +// Deployment (see DeploymentFromWorkload), ordered by workloadKinds. func SourceFromManifests(manifests []byte) (*Bundle, error) { fileMap, err := archive.Unpack(manifests) if err != nil { @@ -31,8 +30,11 @@ func SourceFromManifests(manifests []byte) (*Bundle, error) { } var resources []Resource - var cronJobs []*batchv1.CronJob - hasDeployment := false + type workload struct { + rank int + deploy *appsv1.Deployment + } + var workloads []workload decoder := scheme.Codecs.UniversalDeserializer() @@ -43,6 +45,17 @@ func SourceFromManifests(manifests []byte) (*Bundle, error) { continue } + if u, err := decodeUnstructured(doc); err == nil { + deploy, err := DeploymentFromWorkload(u) + if err != nil { + return nil, fmt.Errorf("%s: %w", name, err) + } + if deploy != nil { + workloads = append(workloads, workload{rank: workloadRank(u.GroupVersionKind()), deploy: deploy}) + continue + } + } + obj, gvk, err := decoder.Decode(doc, nil, nil) if err != nil { // Try as unstructured for unknown types. @@ -55,12 +68,7 @@ func SourceFromManifests(manifests []byte) (*Bundle, error) { continue } - switch o := obj.(type) { - case *appsv1.Deployment: - hasDeployment = true - resources = append(resources, Resource{Object: obj, GVK: *gvk}) - case *batchv1.CronJob: - cronJobs = append(cronJobs, o) + switch obj.(type) { case *corev1.Service, *corev1.ConfigMap, *corev1.Secret, *corev1.ServiceAccount: resources = append(resources, Resource{Object: obj, GVK: *gvk}) default: @@ -73,37 +81,16 @@ func SourceFromManifests(manifests []byte) (*Bundle, error) { } } - resources = append(resources, cronJobResources(cronJobs, hasDeployment)...) + slices.SortStableFunc(workloads, func(a, b workload) int { return a.rank - b.rank }) + for _, w := range workloads { + resources = append(resources, Resource{Object: w.deploy, GVK: appsv1.SchemeGroupVersion.WithKind("Deployment")}) + } return &Bundle{ Resources: resources, }, nil } -// cronJobResources returns the bundle's CronJobs as resources. With no -// Deployment to bridge, the first CronJob (by name) becomes the workload, as a -// Deployment; the rest are kept as they are, like any other resource. -func cronJobResources(cronJobs []*batchv1.CronJob, hasDeployment bool) []Resource { - slices.SortFunc(cronJobs, func(a, b *batchv1.CronJob) int { - return strings.Compare(a.Name, b.Name) - }) - var out []Resource - for i, cj := range cronJobs { - if i == 0 && !hasDeployment { - if len(cronJobs) > 1 { - slog.Warn("Bridging the first of several CronJobs", "cronjob", cj.Name, "count", len(cronJobs)) - } - out = append(out, Resource{ - Object: DeploymentFromCronJob(cj), - GVK: appsv1.SchemeGroupVersion.WithKind("Deployment"), - }) - continue - } - out = append(out, Resource{Object: cj, GVK: batchv1.SchemeGroupVersion.WithKind("CronJob")}) - } - return out -} - // splitYAMLDocuments splits multi-document YAML (separated by ---) into individual documents. func splitYAMLDocuments(data []byte) [][]byte { reader := yaml.NewYAMLReader(bufio.NewReader(bytes.NewReader(data))) diff --git a/pkg/k8s/resources/workload.go b/pkg/k8s/resources/workload.go new file mode 100644 index 0000000..169c3e8 --- /dev/null +++ b/pkg/k8s/resources/workload.go @@ -0,0 +1,97 @@ +package resources + +import ( + "fmt" + "maps" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// workloadKinds are the workload kinds a bridge can be created from, in the +// order a name is looked up and a bundle's workloads are ordered. +var workloadKinds = []schema.GroupVersionKind{ + appsv1.SchemeGroupVersion.WithKind("Deployment"), + appsv1.SchemeGroupVersion.WithKind("StatefulSet"), + appsv1.SchemeGroupVersion.WithKind("DaemonSet"), + appsv1.SchemeGroupVersion.WithKind("ReplicaSet"), + batchv1.SchemeGroupVersion.WithKind("Job"), + batchv1.SchemeGroupVersion.WithKind("CronJob"), +} + +// podTemplatePaths are where a workload keeps its pod template: spec.template, +// or spec.jobTemplate.spec.template for a CronJob. +var podTemplatePaths = [][]string{ + {"spec", "template"}, + {"spec", "jobTemplate", "spec", "template"}, +} + +// DeploymentFromWorkload returns a single-replica Deployment built from any +// workload's pod template and metadata, so the rest of the pipeline only ever +// deals with Deployments. It returns nil when obj has no pod template. +// +// A bridge built from a Job or CronJob runs its pod, not its schedule or +// completion: run the job in the devcontainer with `bridge exec`. +func DeploymentFromWorkload(obj *unstructured.Unstructured) (*appsv1.Deployment, error) { + template, err := podTemplate(obj) + if err != nil || template == nil { + return nil, err + } + // A Deployment's pods must restart Always and can't set a deadline. + template.Spec.RestartPolicy = corev1.RestartPolicyAlways + template.Spec.ActiveDeadlineSeconds = nil + if len(template.Labels) == 0 { + template.Labels = map[string]string{"app": obj.GetName()} + } + + replicas := int32(1) + return &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: obj.GetName(), + Namespace: obj.GetNamespace(), + Labels: obj.GetLabels(), + Annotations: obj.GetAnnotations(), + }, + Spec: appsv1.DeploymentSpec{ + Replicas: &replicas, + Selector: &metav1.LabelSelector{MatchLabels: maps.Clone(template.Labels)}, + Template: *template, + }, + }, nil +} + +// podTemplate returns obj's pod template: the first of podTemplatePaths that +// holds one with containers, or nil. +func podTemplate(obj *unstructured.Unstructured) (*corev1.PodTemplateSpec, error) { + for _, path := range podTemplatePaths { + raw, found, err := unstructured.NestedMap(obj.Object, path...) + if err != nil || !found { + continue + } + if _, ok, _ := unstructured.NestedSlice(raw, "spec", "containers"); !ok { + continue + } + var template corev1.PodTemplateSpec + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw, &template); err != nil { + return nil, fmt.Errorf("invalid pod template in %s %s: %w", obj.GetKind(), obj.GetName(), err) + } + return &template, nil + } + return nil, nil +} + +// workloadRank orders a bundle's workloads by workloadKinds, so a Deployment is +// its source whenever it has one. +func workloadRank(gvk schema.GroupVersionKind) int { + for i, k := range workloadKinds { + if k.Group == gvk.Group && k.Kind == gvk.Kind { + return i + } + } + return len(workloadKinds) +} diff --git a/pkg/k8s/resources/workload_test.go b/pkg/k8s/resources/workload_test.go new file mode 100644 index 0000000..dd2a411 --- /dev/null +++ b/pkg/k8s/resources/workload_test.go @@ -0,0 +1,216 @@ +package resources + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + dynamicfake "k8s.io/client-go/dynamic/fake" + k8stesting "k8s.io/client-go/kubernetes/fake" + "k8s.io/client-go/kubernetes/scheme" +) + +// podTemplateYAML is a pod template, indented to sit under a `template:` key +// at the given depth. +func podTemplateYAML(indent string) string { + return indent + `metadata: +` + indent + ` labels: +` + indent + ` app: my-workload +` + indent + `spec: +` + indent + ` serviceAccountName: my-sa +` + indent + ` restartPolicy: Never +` + indent + ` activeDeadlineSeconds: 600 +` + indent + ` containers: +` + indent + ` - name: main +` + indent + ` image: my-workload:latest +` + indent + ` env: +` + indent + ` - name: FOO +` + indent + ` value: bar +` +} + +const workloadMetadata = `metadata: + name: my-workload + namespace: jobs + labels: + team: platform + annotations: + owner: me +` + +func workloadYAML(apiVersion, kind string) string { + head := "apiVersion: " + apiVersion + "\nkind: " + kind + "\n" + workloadMetadata + if kind == "CronJob" { + return head + "spec:\n schedule: \"0 * * * *\"\n jobTemplate:\n spec:\n template:\n" + podTemplateYAML(" ") + } + return head + "spec:\n template:\n" + podTemplateYAML(" ") +} + +func TestDeploymentFromWorkload_EveryKind(t *testing.T) { + for _, kind := range workloadKinds { + t.Run(kind.Kind, func(t *testing.T) { + obj, err := decodeUnstructured([]byte(workloadYAML(kind.GroupVersion().String(), kind.Kind))) + require.NoError(t, err) + + deploy, err := DeploymentFromWorkload(obj) + require.NoError(t, err) + require.NotNil(t, deploy) + + assert.Equal(t, "my-workload", deploy.Name) + assert.Equal(t, "jobs", deploy.Namespace) + assert.Equal(t, map[string]string{"team": "platform"}, deploy.Labels) + assert.Equal(t, map[string]string{"owner": "me"}, deploy.Annotations) + require.NotNil(t, deploy.Spec.Replicas) + assert.Equal(t, int32(1), *deploy.Spec.Replicas) + assert.Equal(t, map[string]string{"app": "my-workload"}, deploy.Spec.Selector.MatchLabels) + + pod := deploy.Spec.Template.Spec + assert.Equal(t, "my-sa", pod.ServiceAccountName) + assert.Equal(t, corev1.RestartPolicyAlways, pod.RestartPolicy) + assert.Nil(t, pod.ActiveDeadlineSeconds) + require.Len(t, pod.Containers, 1) + assert.Contains(t, pod.Containers[0].Env, corev1.EnvVar{Name: "FOO", Value: "bar"}) + }) + } +} + +func TestDeploymentFromWorkload_NoPodTemplate(t *testing.T) { + obj, err := decodeUnstructured([]byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: config\ndata:\n a: b\n")) + require.NoError(t, err) + + deploy, err := DeploymentFromWorkload(obj) + require.NoError(t, err) + assert.Nil(t, deploy) +} + +func TestDeploymentFromWorkload_UnlabeledTemplate(t *testing.T) { + obj, err := decodeUnstructured([]byte(`apiVersion: batch/v1 +kind: Job +metadata: + name: my-job +spec: + template: + spec: + containers: + - name: main + image: my-job:latest +`)) + require.NoError(t, err) + + deploy, err := DeploymentFromWorkload(obj) + require.NoError(t, err) + assert.Equal(t, map[string]string{"app": "my-job"}, deploy.Spec.Template.Labels) + assert.Equal(t, map[string]string{"app": "my-job"}, deploy.Spec.Selector.MatchLabels) +} + +func TestCreateFromManifests_CronJob(t *testing.T) { + manifests := packTestManifests(t, map[string]string{ + "cron.yaml": workloadYAML("batch/v1", "CronJob"), + }) + + client := k8stesting.NewSimpleClientset() + deployName, bundle, err := testCreateFromManifests(t, client, manifests, "default") + require.NoError(t, err) + assert.Equal(t, testDeployName("my-workload"), deployName) + + for _, r := range bundle.Resources { + _, isCronJob := r.Object.(*batchv1.CronJob) + assert.False(t, isCronJob, "the CronJob must be bridged, not saved as a CronJob") + } + + deploy, err := client.AppsV1().Deployments("default").Get(context.Background(), deployName, metav1.GetOptions{}) + require.NoError(t, err) + assert.Equal(t, "my-sa", deploy.Spec.Template.Spec.ServiceAccountName) + assert.Equal(t, corev1.RestartPolicyAlways, deploy.Spec.Template.Spec.RestartPolicy) +} + +func TestSourceFromManifests_DeploymentIsTheSource(t *testing.T) { + manifests := packTestManifests(t, map[string]string{ + "cron.yaml": workloadYAML("batch/v1", "CronJob"), + "deploy.yaml": `apiVersion: apps/v1 +kind: Deployment +metadata: + name: my-app +spec: + selector: + matchLabels: + app: my-app + template: + metadata: + labels: + app: my-app + spec: + containers: + - name: app + image: my-app:latest +`, + }) + + bundle, err := SourceFromManifests(manifests) + require.NoError(t, err) + assert.Equal(t, "my-app", FindDeploymentName(bundle)) + + var deployments []string + for _, r := range bundle.Resources { + if d, ok := r.Object.(*appsv1.Deployment); ok { + deployments = append(deployments, d.Name) + } + } + assert.Equal(t, []string{"my-app", "my-workload"}, deployments) +} + +func TestSourceFromNamespace(t *testing.T) { + template := corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "my-workload"}}, + Spec: corev1.PodSpec{ + ServiceAccountName: "cron-sa", + Containers: []corev1.Container{{Name: "main", Image: "my-workload:latest"}}, + }, + } + cronJob := &batchv1.CronJob{ + ObjectMeta: metav1.ObjectMeta{Name: "my-workload", Namespace: "default"}, + Spec: batchv1.CronJobSpec{JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{Template: template}, + }}, + } + + t.Run("finds a CronJob", func(t *testing.T) { + client := dynamicfake.NewSimpleDynamicClient(scheme.Scheme, cronJob) + + bundle, err := SourceFromNamespace(context.Background(), client, "default", "my-workload") + require.NoError(t, err) + require.Len(t, bundle.Resources, 1) + deploy, ok := bundle.Resources[0].Object.(*appsv1.Deployment) + require.True(t, ok) + assert.Equal(t, "my-workload", deploy.Name) + assert.Equal(t, "cron-sa", deploy.Spec.Template.Spec.ServiceAccountName) + }) + + t.Run("prefers a Deployment of the same name", func(t *testing.T) { + deployTemplate := *template.DeepCopy() + deployTemplate.Spec.ServiceAccountName = "deploy-sa" + client := dynamicfake.NewSimpleDynamicClient(scheme.Scheme, cronJob, &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "my-workload", Namespace: "default"}, + Spec: appsv1.DeploymentSpec{Template: deployTemplate}, + }) + + bundle, err := SourceFromNamespace(context.Background(), client, "default", "my-workload") + require.NoError(t, err) + deploy := bundle.Resources[0].Object.(*appsv1.Deployment) + assert.Equal(t, "deploy-sa", deploy.Spec.Template.Spec.ServiceAccountName) + }) + + t.Run("is not found when no workload has the name", func(t *testing.T) { + client := dynamicfake.NewSimpleDynamicClient(scheme.Scheme) + + _, err := SourceFromNamespace(context.Background(), client, "default", "missing") + var notFound *WorkloadNotFoundError + assert.ErrorAs(t, err, ¬Found) + }) +} From daeb771625b72588934842d07b27dba82653f421 Mon Sep 17 00:00:00 2001 From: edwardowens Date: Wed, 30 Sep 2026 13:11:53 -0700 Subject: [PATCH 3/4] refactor(k8s): look up a workload's pod template by its kind WorkloadKind is an interface with GVK() and PodTemplatePath(), implemented once per supported kind (Deployment, StatefulSet, DaemonSet, ReplicaSet, Job, CronJob). DeploymentFromWorkload takes the kind and reads the template at its path, instead of trying every path on any object; a supported kind with no template there is an error. Only those kinds are bridged as a Deployment. Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/k8s/resources/source.go | 9 +- pkg/k8s/resources/source_manifests.go | 12 +-- pkg/k8s/resources/workload.go | 131 ++++++++++++++++---------- pkg/k8s/resources/workload_test.go | 28 +++--- 4 files changed, 107 insertions(+), 73 deletions(-) diff --git a/pkg/k8s/resources/source.go b/pkg/k8s/resources/source.go index 6cae4b1..7141d1d 100644 --- a/pkg/k8s/resources/source.go +++ b/pkg/k8s/resources/source.go @@ -18,20 +18,17 @@ import ( // are absent and get cleanly re-injected on the bridge pod. func SourceFromNamespace(ctx context.Context, client dynamic.Interface, namespace, name string) (*Bundle, error) { for _, kind := range workloadKinds { - obj, err := client.Resource(gvkToGVR(kind)).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) + obj, err := client.Resource(gvkToGVR(kind.GVK())).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) if errors.IsNotFound(err) { continue } if err != nil { - return nil, fmt.Errorf("failed to get source %s %s/%s: %w", kind.Kind, namespace, name, err) + return nil, fmt.Errorf("failed to get source %s %s/%s: %w", kind.GVK().Kind, namespace, name, err) } - deploy, err := DeploymentFromWorkload(obj) + deploy, err := DeploymentFromWorkload(kind, obj) if err != nil { return nil, err } - if deploy == nil { - return nil, fmt.Errorf("%s %s/%s has no pod template", kind.Kind, namespace, name) - } return &Bundle{ Resources: []Resource{ {Object: deploy, GVK: appsv1.SchemeGroupVersion.WithKind("Deployment")}, diff --git a/pkg/k8s/resources/source_manifests.go b/pkg/k8s/resources/source_manifests.go index c28625d..56264a5 100644 --- a/pkg/k8s/resources/source_manifests.go +++ b/pkg/k8s/resources/source_manifests.go @@ -46,12 +46,12 @@ func SourceFromManifests(manifests []byte) (*Bundle, error) { } if u, err := decodeUnstructured(doc); err == nil { - deploy, err := DeploymentFromWorkload(u) - if err != nil { - return nil, fmt.Errorf("%s: %w", name, err) - } - if deploy != nil { - workloads = append(workloads, workload{rank: workloadRank(u.GroupVersionKind()), deploy: deploy}) + if i := workloadKindIndex(u.GroupVersionKind()); i >= 0 { + deploy, err := DeploymentFromWorkload(workloadKinds[i], u) + if err != nil { + return nil, fmt.Errorf("%s: %w", name, err) + } + workloads = append(workloads, workload{rank: i, deploy: deploy}) continue } } diff --git a/pkg/k8s/resources/workload.go b/pkg/k8s/resources/workload.go index 169c3e8..df3e7f2 100644 --- a/pkg/k8s/resources/workload.go +++ b/pkg/k8s/resources/workload.go @@ -3,6 +3,7 @@ package resources import ( "fmt" "maps" + "strings" appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" @@ -13,34 +14,95 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" ) +// WorkloadKind is a kind of workload a bridge can be created from. +type WorkloadKind interface { + // GVK is the workload's group, version and kind. + GVK() schema.GroupVersionKind + // PodTemplatePath is the field path of the workload's pod template. + PodTemplatePath() []string +} + +type deploymentKind struct{} + +func (deploymentKind) GVK() schema.GroupVersionKind { + return appsv1.SchemeGroupVersion.WithKind("Deployment") +} +func (deploymentKind) PodTemplatePath() []string { return []string{"spec", "template"} } + +type statefulSetKind struct{} + +func (statefulSetKind) GVK() schema.GroupVersionKind { + return appsv1.SchemeGroupVersion.WithKind("StatefulSet") +} +func (statefulSetKind) PodTemplatePath() []string { return []string{"spec", "template"} } + +type daemonSetKind struct{} + +func (daemonSetKind) GVK() schema.GroupVersionKind { + return appsv1.SchemeGroupVersion.WithKind("DaemonSet") +} +func (daemonSetKind) PodTemplatePath() []string { return []string{"spec", "template"} } + +type replicaSetKind struct{} + +func (replicaSetKind) GVK() schema.GroupVersionKind { + return appsv1.SchemeGroupVersion.WithKind("ReplicaSet") +} +func (replicaSetKind) PodTemplatePath() []string { return []string{"spec", "template"} } + +type jobKind struct{} + +func (jobKind) GVK() schema.GroupVersionKind { + return batchv1.SchemeGroupVersion.WithKind("Job") +} +func (jobKind) PodTemplatePath() []string { return []string{"spec", "template"} } + +type cronJobKind struct{} + +func (cronJobKind) GVK() schema.GroupVersionKind { + return batchv1.SchemeGroupVersion.WithKind("CronJob") +} +func (cronJobKind) PodTemplatePath() []string { + return []string{"spec", "jobTemplate", "spec", "template"} +} + // workloadKinds are the workload kinds a bridge can be created from, in the // order a name is looked up and a bundle's workloads are ordered. -var workloadKinds = []schema.GroupVersionKind{ - appsv1.SchemeGroupVersion.WithKind("Deployment"), - appsv1.SchemeGroupVersion.WithKind("StatefulSet"), - appsv1.SchemeGroupVersion.WithKind("DaemonSet"), - appsv1.SchemeGroupVersion.WithKind("ReplicaSet"), - batchv1.SchemeGroupVersion.WithKind("Job"), - batchv1.SchemeGroupVersion.WithKind("CronJob"), +var workloadKinds = []WorkloadKind{ + deploymentKind{}, + statefulSetKind{}, + daemonSetKind{}, + replicaSetKind{}, + jobKind{}, + cronJobKind{}, } -// podTemplatePaths are where a workload keeps its pod template: spec.template, -// or spec.jobTemplate.spec.template for a CronJob. -var podTemplatePaths = [][]string{ - {"spec", "template"}, - {"spec", "jobTemplate", "spec", "template"}, +// workloadKindIndex returns gvk's position in workloadKinds, or -1 if it isn't a +// workload kind. +func workloadKindIndex(gvk schema.GroupVersionKind) int { + for i, kind := range workloadKinds { + if k := kind.GVK(); k.Group == gvk.Group && k.Kind == gvk.Kind { + return i + } + } + return -1 } -// DeploymentFromWorkload returns a single-replica Deployment built from any +// DeploymentFromWorkload returns a single-replica Deployment built from a // workload's pod template and metadata, so the rest of the pipeline only ever -// deals with Deployments. It returns nil when obj has no pod template. +// deals with Deployments. // // A bridge built from a Job or CronJob runs its pod, not its schedule or // completion: run the job in the devcontainer with `bridge exec`. -func DeploymentFromWorkload(obj *unstructured.Unstructured) (*appsv1.Deployment, error) { - template, err := podTemplate(obj) - if err != nil || template == nil { - return nil, err +func DeploymentFromWorkload(kind WorkloadKind, obj *unstructured.Unstructured) (*appsv1.Deployment, error) { + path := kind.PodTemplatePath() + raw, found, err := unstructured.NestedMap(obj.Object, path...) + if err != nil || !found { + return nil, fmt.Errorf("%s %s has no pod template at %s", kind.GVK().Kind, obj.GetName(), strings.Join(path, ".")) + } + var template corev1.PodTemplateSpec + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw, &template); err != nil { + return nil, fmt.Errorf("invalid pod template in %s %s: %w", kind.GVK().Kind, obj.GetName(), err) } // A Deployment's pods must restart Always and can't set a deadline. template.Spec.RestartPolicy = corev1.RestartPolicyAlways @@ -60,38 +122,7 @@ func DeploymentFromWorkload(obj *unstructured.Unstructured) (*appsv1.Deployment, Spec: appsv1.DeploymentSpec{ Replicas: &replicas, Selector: &metav1.LabelSelector{MatchLabels: maps.Clone(template.Labels)}, - Template: *template, + Template: template, }, }, nil } - -// podTemplate returns obj's pod template: the first of podTemplatePaths that -// holds one with containers, or nil. -func podTemplate(obj *unstructured.Unstructured) (*corev1.PodTemplateSpec, error) { - for _, path := range podTemplatePaths { - raw, found, err := unstructured.NestedMap(obj.Object, path...) - if err != nil || !found { - continue - } - if _, ok, _ := unstructured.NestedSlice(raw, "spec", "containers"); !ok { - continue - } - var template corev1.PodTemplateSpec - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw, &template); err != nil { - return nil, fmt.Errorf("invalid pod template in %s %s: %w", obj.GetKind(), obj.GetName(), err) - } - return &template, nil - } - return nil, nil -} - -// workloadRank orders a bundle's workloads by workloadKinds, so a Deployment is -// its source whenever it has one. -func workloadRank(gvk schema.GroupVersionKind) int { - for i, k := range workloadKinds { - if k.Group == gvk.Group && k.Kind == gvk.Kind { - return i - } - } - return len(workloadKinds) -} diff --git a/pkg/k8s/resources/workload_test.go b/pkg/k8s/resources/workload_test.go index dd2a411..32dc3ce 100644 --- a/pkg/k8s/resources/workload_test.go +++ b/pkg/k8s/resources/workload_test.go @@ -54,11 +54,12 @@ func workloadYAML(apiVersion, kind string) string { func TestDeploymentFromWorkload_EveryKind(t *testing.T) { for _, kind := range workloadKinds { - t.Run(kind.Kind, func(t *testing.T) { - obj, err := decodeUnstructured([]byte(workloadYAML(kind.GroupVersion().String(), kind.Kind))) + gvk := kind.GVK() + t.Run(gvk.Kind, func(t *testing.T) { + obj, err := decodeUnstructured([]byte(workloadYAML(gvk.GroupVersion().String(), gvk.Kind))) require.NoError(t, err) - deploy, err := DeploymentFromWorkload(obj) + deploy, err := DeploymentFromWorkload(kind, obj) require.NoError(t, err) require.NotNil(t, deploy) @@ -81,12 +82,11 @@ func TestDeploymentFromWorkload_EveryKind(t *testing.T) { } func TestDeploymentFromWorkload_NoPodTemplate(t *testing.T) { - obj, err := decodeUnstructured([]byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: config\ndata:\n a: b\n")) + obj, err := decodeUnstructured([]byte("apiVersion: batch/v1\nkind: CronJob\nmetadata:\n name: my-cron\nspec:\n schedule: \"0 * * * *\"\n")) require.NoError(t, err) - deploy, err := DeploymentFromWorkload(obj) - require.NoError(t, err) - assert.Nil(t, deploy) + _, err = DeploymentFromWorkload(cronJobKind{}, obj) + assert.ErrorContains(t, err, "no pod template at spec.jobTemplate.spec.template") } func TestDeploymentFromWorkload_UnlabeledTemplate(t *testing.T) { @@ -103,7 +103,7 @@ spec: `)) require.NoError(t, err) - deploy, err := DeploymentFromWorkload(obj) + deploy, err := DeploymentFromWorkload(jobKind{}, obj) require.NoError(t, err) assert.Equal(t, map[string]string{"app": "my-job"}, deploy.Spec.Template.Labels) assert.Equal(t, map[string]string{"app": "my-job"}, deploy.Spec.Selector.MatchLabels) @@ -132,7 +132,8 @@ func TestCreateFromManifests_CronJob(t *testing.T) { func TestSourceFromManifests_DeploymentIsTheSource(t *testing.T) { manifests := packTestManifests(t, map[string]string{ - "cron.yaml": workloadYAML("batch/v1", "CronJob"), + "cron.yaml": workloadYAML("batch/v1", "CronJob"), + "config.yaml": "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: config\ndata:\n a: b\n", "deploy.yaml": `apiVersion: apps/v1 kind: Deployment metadata: @@ -157,12 +158,17 @@ spec: assert.Equal(t, "my-app", FindDeploymentName(bundle)) var deployments []string + var configMaps int for _, r := range bundle.Resources { - if d, ok := r.Object.(*appsv1.Deployment); ok { - deployments = append(deployments, d.Name) + switch o := r.Object.(type) { + case *appsv1.Deployment: + deployments = append(deployments, o.Name) + case *corev1.ConfigMap: + configMaps++ } } assert.Equal(t, []string{"my-app", "my-workload"}, deployments) + assert.Equal(t, 1, configMaps, "a ConfigMap is kept as a ConfigMap") } func TestSourceFromNamespace(t *testing.T) { From 63c1cbecf5406699ae174d77e8d12893492acc95 Mon Sep 17 00:00:00 2001 From: edwardowens Date: Wed, 30 Sep 2026 13:14:11 -0700 Subject: [PATCH 4/4] refactor(k8s): pointer receivers for the WorkloadKind implementations Co-Authored-By: Claude Opus 5.5 (1M context) --- pkg/k8s/resources/workload.go | 36 +++++++++++++++--------------- pkg/k8s/resources/workload_test.go | 4 ++-- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/pkg/k8s/resources/workload.go b/pkg/k8s/resources/workload.go index df3e7f2..f37dde8 100644 --- a/pkg/k8s/resources/workload.go +++ b/pkg/k8s/resources/workload.go @@ -24,57 +24,57 @@ type WorkloadKind interface { type deploymentKind struct{} -func (deploymentKind) GVK() schema.GroupVersionKind { +func (*deploymentKind) GVK() schema.GroupVersionKind { return appsv1.SchemeGroupVersion.WithKind("Deployment") } -func (deploymentKind) PodTemplatePath() []string { return []string{"spec", "template"} } +func (*deploymentKind) PodTemplatePath() []string { return []string{"spec", "template"} } type statefulSetKind struct{} -func (statefulSetKind) GVK() schema.GroupVersionKind { +func (*statefulSetKind) GVK() schema.GroupVersionKind { return appsv1.SchemeGroupVersion.WithKind("StatefulSet") } -func (statefulSetKind) PodTemplatePath() []string { return []string{"spec", "template"} } +func (*statefulSetKind) PodTemplatePath() []string { return []string{"spec", "template"} } type daemonSetKind struct{} -func (daemonSetKind) GVK() schema.GroupVersionKind { +func (*daemonSetKind) GVK() schema.GroupVersionKind { return appsv1.SchemeGroupVersion.WithKind("DaemonSet") } -func (daemonSetKind) PodTemplatePath() []string { return []string{"spec", "template"} } +func (*daemonSetKind) PodTemplatePath() []string { return []string{"spec", "template"} } type replicaSetKind struct{} -func (replicaSetKind) GVK() schema.GroupVersionKind { +func (*replicaSetKind) GVK() schema.GroupVersionKind { return appsv1.SchemeGroupVersion.WithKind("ReplicaSet") } -func (replicaSetKind) PodTemplatePath() []string { return []string{"spec", "template"} } +func (*replicaSetKind) PodTemplatePath() []string { return []string{"spec", "template"} } type jobKind struct{} -func (jobKind) GVK() schema.GroupVersionKind { +func (*jobKind) GVK() schema.GroupVersionKind { return batchv1.SchemeGroupVersion.WithKind("Job") } -func (jobKind) PodTemplatePath() []string { return []string{"spec", "template"} } +func (*jobKind) PodTemplatePath() []string { return []string{"spec", "template"} } type cronJobKind struct{} -func (cronJobKind) GVK() schema.GroupVersionKind { +func (*cronJobKind) GVK() schema.GroupVersionKind { return batchv1.SchemeGroupVersion.WithKind("CronJob") } -func (cronJobKind) PodTemplatePath() []string { +func (*cronJobKind) PodTemplatePath() []string { return []string{"spec", "jobTemplate", "spec", "template"} } // workloadKinds are the workload kinds a bridge can be created from, in the // order a name is looked up and a bundle's workloads are ordered. var workloadKinds = []WorkloadKind{ - deploymentKind{}, - statefulSetKind{}, - daemonSetKind{}, - replicaSetKind{}, - jobKind{}, - cronJobKind{}, + &deploymentKind{}, + &statefulSetKind{}, + &daemonSetKind{}, + &replicaSetKind{}, + &jobKind{}, + &cronJobKind{}, } // workloadKindIndex returns gvk's position in workloadKinds, or -1 if it isn't a diff --git a/pkg/k8s/resources/workload_test.go b/pkg/k8s/resources/workload_test.go index 32dc3ce..59b60dc 100644 --- a/pkg/k8s/resources/workload_test.go +++ b/pkg/k8s/resources/workload_test.go @@ -85,7 +85,7 @@ func TestDeploymentFromWorkload_NoPodTemplate(t *testing.T) { obj, err := decodeUnstructured([]byte("apiVersion: batch/v1\nkind: CronJob\nmetadata:\n name: my-cron\nspec:\n schedule: \"0 * * * *\"\n")) require.NoError(t, err) - _, err = DeploymentFromWorkload(cronJobKind{}, obj) + _, err = DeploymentFromWorkload(&cronJobKind{}, obj) assert.ErrorContains(t, err, "no pod template at spec.jobTemplate.spec.template") } @@ -103,7 +103,7 @@ spec: `)) require.NoError(t, err) - deploy, err := DeploymentFromWorkload(jobKind{}, obj) + deploy, err := DeploymentFromWorkload(&jobKind{}, obj) require.NoError(t, err) assert.Equal(t, map[string]string{"app": "my-job"}, deploy.Spec.Template.Labels) assert.Equal(t, map[string]string{"app": "my-job"}, deploy.Spec.Selector.MatchLabels)