From 21efc74e02a0f704b3285d63767e6378ed50071a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20L=C3=B3pez=20Barba?= Date: Fri, 21 Feb 2025 14:29:13 +0100 Subject: [PATCH] feat: allow proxy protobuff integration (#4668) * feat: allow proxy protobuff integration Signed-off-by: Javier Lopez * fix: golangci Signed-off-by: Javier Lopez * add support for protobuff Signed-off-by: Javier Lopez * fix: imports Signed-off-by: Javier Lopez * refactor: address feedback Signed-off-by: Javier Lopez * doc: add explanaiton Signed-off-by: Javier Lopez * init translators Signed-off-by: Javier Lopez * fix: golangci Signed-off-by: Javier Lopez * fix: tests Signed-off-by: Javier Lopez * fix: handle unsupported content type in proxy handler Signed-off-by: Javier Lopez * add metrics Signed-off-by: Javier Lopez * refactor: rename tracking function for unsupported content type Signed-off-by: Javier Lopez * fix: update unsupported content type event name for clarity Signed-off-by: Javier Lopez --------- Signed-off-by: Javier Lopez --- Dockerfile | 4 +- pkg/analytics/proxy.go | 26 ++ pkg/analytics/proxy_test.go | 34 ++ pkg/deployable/deploy.go | 2 + pkg/deployable/deploy_test.go | 1 + pkg/deployable/json_translator.go | 265 ++++++++++++ pkg/deployable/json_translator_test.go | 430 +++++++++++++++++++ pkg/deployable/protobuf_translator.go | 225 ++++++++++ pkg/deployable/protobuf_translator_test.go | 458 +++++++++++++++++++++ pkg/deployable/proxy.go | 264 ++---------- pkg/deployable/proxy_test.go | 54 --- 11 files changed, 1470 insertions(+), 293 deletions(-) create mode 100644 pkg/analytics/proxy.go create mode 100644 pkg/analytics/proxy_test.go create mode 100644 pkg/deployable/json_translator.go create mode 100644 pkg/deployable/json_translator_test.go create mode 100644 pkg/deployable/protobuf_translator.go create mode 100644 pkg/deployable/protobuf_translator_test.go diff --git a/Dockerfile b/Dockerfile index 0558aefe1e55..c838d847ba27 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ -ARG KUBECTL_VERSION=1.31.5 -ARG HELM_VERSION=3.16.4 +ARG KUBECTL_VERSION=1.32.1 +ARG HELM_VERSION=3.17.0 ARG SYNCTHING_VERSION=1.29.2 ARG OKTETO_REMOTE_VERSION=0.6.2 ARG OKTETO_SUPERVISOR_VERSION=0.4.1 diff --git a/pkg/analytics/proxy.go b/pkg/analytics/proxy.go new file mode 100644 index 000000000000..96909f0520e4 --- /dev/null +++ b/pkg/analytics/proxy.go @@ -0,0 +1,26 @@ +// Copyright 2025 The Okteto Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package analytics + +const ( + unsupportedProxyContentTypeEvent = "Unsupported Proxy Content Type" + contentTypeKey = "contentType" +) + +// TrackUnsupportedContentType sends a tracking event to mixpanel when the proxy receives a request with an unsupported content type +func (a *Tracker) TrackUnsupportedContentType(contentType string) { + a.trackFn(unsupportedProxyContentTypeEvent, true, map[string]interface{}{ + contentTypeKey: contentType, + }) +} diff --git a/pkg/analytics/proxy_test.go b/pkg/analytics/proxy_test.go new file mode 100644 index 000000000000..f5da765fa7bb --- /dev/null +++ b/pkg/analytics/proxy_test.go @@ -0,0 +1,34 @@ +// Copyright 2025 The Okteto Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package analytics + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProxyUnsupportedContentType(t *testing.T) { + contentType := "" + event := "" + tracker := Tracker{ + trackFn: func(e string, success bool, props map[string]any) { + event = e + contentType = props[contentTypeKey].(string) + }, + } + tracker.TrackUnsupportedContentType("application/json") + assert.Equal(t, unsupportedProxyContentTypeEvent, event) + assert.Equal(t, "application/json", contentType) +} diff --git a/pkg/deployable/deploy.go b/pkg/deployable/deploy.go index a7f0a431ec2e..e8132e815eb0 100644 --- a/pkg/deployable/deploy.go +++ b/pkg/deployable/deploy.go @@ -53,6 +53,7 @@ type ProxyInterface interface { GetToken() string SetName(name string) SetDivert(driver divert.Driver) + InitTranslator() } // KubeConfigHandler defines the operations to handle the kubeconfig file @@ -224,6 +225,7 @@ func (r *DeployRunner) RunDeploy(ctx context.Context, params DeployParameters) e r.Proxy.SetDivert(driver) r.DivertDeployer = driver } + r.Proxy.InitTranslator() os.Setenv(constants.OktetoNameEnvVar, params.Name) diff --git a/pkg/deployable/deploy_test.go b/pkg/deployable/deploy_test.go index 51eca3b0e27f..ff81723545b8 100644 --- a/pkg/deployable/deploy_test.go +++ b/pkg/deployable/deploy_test.go @@ -88,6 +88,7 @@ func (f *fakeProxy) SetName(name string) { func (f *fakeProxy) SetDivert(driver divert.Driver) { f.Called(driver) } +func (f *fakeProxy) InitTranslator() {} type fakeExecutor struct { mock.Mock diff --git a/pkg/deployable/json_translator.go b/pkg/deployable/json_translator.go new file mode 100644 index 000000000000..97223eec144e --- /dev/null +++ b/pkg/deployable/json_translator.go @@ -0,0 +1,265 @@ +// Copyright 2025 The Okteto Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package deployable + +import ( + "encoding/json" + "fmt" + + "github.com/okteto/okteto/cmd/utils" + "github.com/okteto/okteto/pkg/divert" + "github.com/okteto/okteto/pkg/k8s/labels" + oktetoLog "github.com/okteto/okteto/pkg/log" + "github.com/okteto/okteto/pkg/model" + istioNetworkingV1beta1 "istio.io/api/networking/v1beta1" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + apiv1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type jsonTranslator struct { + name string + divertDriver divert.Driver +} + +func newJSONTranslator(name string, divertDriver divert.Driver) *jsonTranslator { + return &jsonTranslator{ + name: name, + divertDriver: divertDriver, + } +} + +func (j *jsonTranslator) Translate(b []byte) ([]byte, error) { + var body map[string]json.RawMessage + if err := json.Unmarshal(b, &body); err != nil { + oktetoLog.Infof("error unmarshalling resource body on proxy: %s", err.Error()) + return nil, nil + } + + if err := j.translateMetadata(body); err != nil { + return nil, err + } + + var typeMeta metav1.TypeMeta + if err := json.Unmarshal(b, &typeMeta); err != nil { + oktetoLog.Infof("error unmarshalling typemeta on proxy: %s", err.Error()) + return nil, nil + } + + switch typeMeta.Kind { + case "Deployment": + if err := j.translateDeploymentSpec(body); err != nil { + return nil, err + } + case "StatefulSet": + if err := j.translateStatefulSetSpec(body); err != nil { + return nil, err + } + case "Job": + if err := j.translateJobSpec(body); err != nil { + return nil, err + } + case "CronJob": + if err := j.translateCronJobSpec(body); err != nil { + return nil, err + } + case "DaemonSet": + if err := j.translateDaemonSetSpec(body); err != nil { + return nil, err + } + case "ReplicationController": + if err := j.translateReplicationControllerSpec(body); err != nil { + return nil, err + } + case "ReplicaSet": + if err := j.translateReplicaSetSpec(body); err != nil { + return nil, err + } + case "VirtualService": + if err := j.translateVirtualServiceSpec(body); err != nil { + return nil, err + } + } + + return json.Marshal(body) +} + +func (j *jsonTranslator) translateMetadata(body map[string]json.RawMessage) error { + m, ok := body["metadata"] + if !ok { + return fmt.Errorf("request body doesn't have metadata field") + } + + var metadata metav1.ObjectMeta + if err := json.Unmarshal(m, &metadata); err != nil { + oktetoLog.Infof("error unmarshalling objectmeta on proxy: %s", err.Error()) + return nil + } + + labels.SetInMetadata(&metadata, model.DeployedByLabel, j.name) + + if metadata.Annotations == nil { + metadata.Annotations = map[string]string{} + } + if utils.IsOktetoRepo() { + metadata.Annotations[model.OktetoSampleAnnotation] = "true" + } + + metadataAsByte, err := json.Marshal(metadata) + if err != nil { + return fmt.Errorf("could not process resource's metadata: %w", err) + } + + body["metadata"] = metadataAsByte + + return nil +} + +func (j *jsonTranslator) translateDeploymentSpec(body map[string]json.RawMessage) error { + var spec appsv1.DeploymentSpec + if err := json.Unmarshal(body["spec"], &spec); err != nil { + oktetoLog.Infof("error unmarshalling deployment spec on proxy: %s", err.Error()) + return nil + } + labels.SetInMetadata(&spec.Template.ObjectMeta, model.DeployedByLabel, j.name) + spec.Template.Spec = j.applyDivertToPod(spec.Template.Spec) + specAsByte, err := json.Marshal(spec) + if err != nil { + return fmt.Errorf("could not process deployment's spec: %w", err) + } + body["spec"] = specAsByte + return nil +} + +func (j *jsonTranslator) translateStatefulSetSpec(body map[string]json.RawMessage) error { + var spec appsv1.StatefulSetSpec + if err := json.Unmarshal(body["spec"], &spec); err != nil { + oktetoLog.Infof("error unmarshalling statefulset spec on proxy: %s", err.Error()) + return nil + } + labels.SetInMetadata(&spec.Template.ObjectMeta, model.DeployedByLabel, j.name) + spec.Template.Spec = j.applyDivertToPod(spec.Template.Spec) + specAsByte, err := json.Marshal(spec) + if err != nil { + return fmt.Errorf("could not process statefulset's spec: %w", err) + } + body["spec"] = specAsByte + return nil +} + +func (j *jsonTranslator) translateJobSpec(body map[string]json.RawMessage) error { + var spec batchv1.JobSpec + if err := json.Unmarshal(body["spec"], &spec); err != nil { + oktetoLog.Infof("error unmarshalling job spec on proxy: %s", err.Error()) + return nil + } + labels.SetInMetadata(&spec.Template.ObjectMeta, model.DeployedByLabel, j.name) + spec.Template.Spec = j.applyDivertToPod(spec.Template.Spec) + specAsByte, err := json.Marshal(spec) + if err != nil { + return fmt.Errorf("could not process job's spec: %w", err) + } + body["spec"] = specAsByte + return nil +} + +func (j *jsonTranslator) translateCronJobSpec(body map[string]json.RawMessage) error { + var spec batchv1.CronJobSpec + if err := json.Unmarshal(body["spec"], &spec); err != nil { + oktetoLog.Infof("error unmarshalling cronjob spec on proxy: %s", err.Error()) + return nil + } + labels.SetInMetadata(&spec.JobTemplate.Spec.Template.ObjectMeta, model.DeployedByLabel, j.name) + spec.JobTemplate.Spec.Template.Spec = j.applyDivertToPod(spec.JobTemplate.Spec.Template.Spec) + specAsByte, err := json.Marshal(spec) + if err != nil { + return fmt.Errorf("could not process cronjob's spec: %w", err) + } + body["spec"] = specAsByte + return nil +} + +func (j *jsonTranslator) translateDaemonSetSpec(body map[string]json.RawMessage) error { + var spec appsv1.DaemonSetSpec + if err := json.Unmarshal(body["spec"], &spec); err != nil { + oktetoLog.Infof("error unmarshalling daemonset spec on proxy: %s", err.Error()) + return nil + } + labels.SetInMetadata(&spec.Template.ObjectMeta, model.DeployedByLabel, j.name) + spec.Template.Spec = j.applyDivertToPod(spec.Template.Spec) + specAsByte, err := json.Marshal(spec) + if err != nil { + return fmt.Errorf("could not process daemonset's spec: %w", err) + } + body["spec"] = specAsByte + return nil +} + +func (j *jsonTranslator) translateReplicationControllerSpec(body map[string]json.RawMessage) error { + var spec apiv1.ReplicationControllerSpec + if err := json.Unmarshal(body["spec"], &spec); err != nil { + oktetoLog.Infof("error unmarshalling replicationcontroller on proxy: %s", err.Error()) + return nil + } + labels.SetInMetadata(&spec.Template.ObjectMeta, model.DeployedByLabel, j.name) + spec.Template.Spec = j.applyDivertToPod(spec.Template.Spec) + specAsByte, err := json.Marshal(spec) + if err != nil { + return fmt.Errorf("could not process replicationcontroller's spec: %w", err) + } + body["spec"] = specAsByte + return nil +} + +func (j *jsonTranslator) translateReplicaSetSpec(body map[string]json.RawMessage) error { + var spec appsv1.ReplicaSetSpec + if err := json.Unmarshal(body["spec"], &spec); err != nil { + oktetoLog.Infof("error unmarshalling replicaset on proxy: %s", err.Error()) + return nil + } + labels.SetInMetadata(&spec.Template.ObjectMeta, model.DeployedByLabel, j.name) + spec.Template.Spec = j.applyDivertToPod(spec.Template.Spec) + specAsByte, err := json.Marshal(spec) + if err != nil { + return fmt.Errorf("could not process replicaset's spec: %w", err) + } + body["spec"] = specAsByte + return nil +} +func (j *jsonTranslator) applyDivertToPod(podSpec apiv1.PodSpec) apiv1.PodSpec { + if j.divertDriver == nil { + return podSpec + } + return j.divertDriver.UpdatePod(podSpec) +} + +func (j *jsonTranslator) translateVirtualServiceSpec(body map[string]json.RawMessage) error { + if j.divertDriver == nil { + return nil + } + + var spec *istioNetworkingV1beta1.VirtualService + if err := json.Unmarshal(body["spec"], &spec); err != nil { + oktetoLog.Infof("error unmarshalling replicaset on proxy: %s", err.Error()) + return nil + } + j.divertDriver.UpdateVirtualService(spec) + specAsByte, err := json.Marshal(spec) + if err != nil { + return fmt.Errorf("could not process virtual service's spec: %w", err) + } + body["spec"] = specAsByte + return nil +} diff --git a/pkg/deployable/json_translator_test.go b/pkg/deployable/json_translator_test.go new file mode 100644 index 000000000000..7b020cf1e5e9 --- /dev/null +++ b/pkg/deployable/json_translator_test.go @@ -0,0 +1,430 @@ +// Copyright 2025 The Okteto Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package deployable + +import ( + "context" + "encoding/json" + "reflect" + "testing" + + "github.com/okteto/okteto/pkg/divert" + "github.com/okteto/okteto/pkg/model" + "github.com/stretchr/testify/assert" + istioNetworkingV1beta1 "istio.io/api/networking/v1beta1" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + apiv1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// fakeDivertDriver implements divert.Driver so we can simulate divert behavior. +type fakeDivertDriver struct{} + +func (f *fakeDivertDriver) UpdatePod(podSpec apiv1.PodSpec) apiv1.PodSpec { + podSpec.Containers = append(podSpec.Containers, apiv1.Container{Name: "diverted"}) + return podSpec +} + +func (f *fakeDivertDriver) UpdateVirtualService(vs *istioNetworkingV1beta1.VirtualService) { + vs.Hosts = []string{"diverted.example.com"} +} + +func (f *fakeDivertDriver) Deploy(ctx context.Context) error { + return nil +} + +func (f *fakeDivertDriver) Destroy(ctx context.Context) error { + return nil +} + +// runTranslatorTest is a helper that marshals the input, runs the translator, +// and returns the resulting JSON as a map. +func runTranslatorTest(t *testing.T, input interface{}, translatorName string, dDriver divert.Driver) map[string]json.RawMessage { + b, err := json.Marshal(input) + assert.NoError(t, err, "failed to marshal input") + translator := newJSONTranslator(translatorName, dDriver) + outBytes, err := translator.Translate(b) + assert.NoError(t, err, "Translate returned error") + var out map[string]json.RawMessage + err = json.Unmarshal(outBytes, &out) + assert.NoError(t, err, "failed to unmarshal translator output") + return out +} + +func TestJSONTranslatorTranslateDeployment(t *testing.T) { + input := map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "deployment-test", + "labels": map[string]string{"original": "label"}, + }, + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]string{"app": "myapp"}, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{}, + }, + }, + }, + } + translatorName := "test-deployer1" + out := runTranslatorTest(t, input, translatorName, &fakeDivertDriver{}) + + // Verify top-level metadata. + var meta metav1.ObjectMeta + err := json.Unmarshal(out["metadata"], &meta) + assert.NoError(t, err, "failed to unmarshal metadata") + assert.Equal(t, translatorName, meta.Labels[model.DeployedByLabel], "expected metadata label to be set") + + // Verify Deployment spec template metadata. + var spec appsv1.DeploymentSpec + err = json.Unmarshal(out["spec"], &spec) + assert.NoError(t, err, "failed to unmarshal deployment spec") + assert.Equal(t, translatorName, spec.Template.ObjectMeta.Labels[model.DeployedByLabel], "expected template metadata label to be set") + + // Verify that the divert driver was applied (container "diverted" added). + found := false + for _, c := range spec.Template.Spec.Containers { + if c.Name == "diverted" { + found = true + break + } + } + assert.True(t, found, "expected diverted container to be added by divert driver") +} + +func TestJSONTranslatorTranslateStatefulSet(t *testing.T) { + input := map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "StatefulSet", + "metadata": map[string]interface{}{ + "name": "statefulset-test", + "labels": map[string]string{}, + }, + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]string{"app": "statefulset"}, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{}, + }, + }, + }, + } + translatorName := "test-deployer" + out := runTranslatorTest(t, input, translatorName, &fakeDivertDriver{}) + + var meta metav1.ObjectMeta + err := json.Unmarshal(out["metadata"], &meta) + assert.NoError(t, err, "failed to unmarshal metadata") + assert.Equal(t, translatorName, meta.Labels[model.DeployedByLabel], "expected metadata label to be set") + + var spec appsv1.StatefulSetSpec + err = json.Unmarshal(out["spec"], &spec) + assert.NoError(t, err, "failed to unmarshal statefulset spec") + assert.Equal(t, translatorName, spec.Template.ObjectMeta.Labels[model.DeployedByLabel], "expected template metadata label to be set") + + found := false + for _, c := range spec.Template.Spec.Containers { + if c.Name == "diverted" { + found = true + break + } + } + assert.True(t, found, "expected diverted container to be added by divert driver") +} + +func TestJSONTranslatorTranslateJob(t *testing.T) { + input := map[string]interface{}{ + "apiVersion": "batch/v1", + "kind": "Job", + "metadata": map[string]interface{}{ + "name": "job-test", + "labels": map[string]string{}, + }, + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]string{"job": "true"}, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{}, + }, + }, + }, + } + translatorName := "test-deployer" + out := runTranslatorTest(t, input, translatorName, &fakeDivertDriver{}) + + var meta metav1.ObjectMeta + err := json.Unmarshal(out["metadata"], &meta) + assert.NoError(t, err, "failed to unmarshal metadata") + assert.Equal(t, translatorName, meta.Labels[model.DeployedByLabel], "expected metadata label to be set") + + var spec batchv1.JobSpec + err = json.Unmarshal(out["spec"], &spec) + assert.NoError(t, err, "failed to unmarshal job spec") + assert.Equal(t, translatorName, spec.Template.ObjectMeta.Labels[model.DeployedByLabel], "expected template metadata label to be set") + + found := false + for _, c := range spec.Template.Spec.Containers { + if c.Name == "diverted" { + found = true + break + } + } + assert.True(t, found, "expected diverted container to be added by divert driver") +} + +func TestJSONTranslatorTranslateCronJob(t *testing.T) { + input := map[string]interface{}{ + "apiVersion": "batch/v1", + "kind": "CronJob", + "metadata": map[string]interface{}{ + "name": "cronjob-test", + "labels": map[string]string{}, + }, + "spec": map[string]interface{}{ + "jobTemplate": map[string]interface{}{ + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]string{"cron": "job"}, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{}, + }, + }, + }, + }, + }, + } + translatorName := "test-deployer" + out := runTranslatorTest(t, input, translatorName, &fakeDivertDriver{}) + + var meta metav1.ObjectMeta + err := json.Unmarshal(out["metadata"], &meta) + assert.NoError(t, err, "failed to unmarshal metadata") + assert.Equal(t, translatorName, meta.Labels[model.DeployedByLabel], "expected metadata label to be set") + + var spec batchv1.CronJobSpec + err = json.Unmarshal(out["spec"], &spec) + assert.NoError(t, err, "failed to unmarshal cronjob spec") + assert.Equal(t, translatorName, spec.JobTemplate.Spec.Template.ObjectMeta.Labels[model.DeployedByLabel], "expected template metadata label to be set") + + found := false + for _, c := range spec.JobTemplate.Spec.Template.Spec.Containers { + if c.Name == "diverted" { + found = true + break + } + } + assert.True(t, found, "expected diverted container to be added by divert driver") +} + +func TestJSONTranslatorTranslateDaemonSet(t *testing.T) { + input := map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "DaemonSet", + "metadata": map[string]interface{}{ + "name": "daemonset-test", + "labels": map[string]string{}, + }, + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]string{"daemon": "true"}, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{}, + }, + }, + }, + } + translatorName := "test-deployer" + out := runTranslatorTest(t, input, translatorName, &fakeDivertDriver{}) + + var meta metav1.ObjectMeta + err := json.Unmarshal(out["metadata"], &meta) + assert.NoError(t, err, "failed to unmarshal metadata") + assert.Equal(t, translatorName, meta.Labels[model.DeployedByLabel], "expected metadata label to be set") + + var spec appsv1.DaemonSetSpec + err = json.Unmarshal(out["spec"], &spec) + assert.NoError(t, err, "failed to unmarshal daemonset spec") + assert.Equal(t, translatorName, spec.Template.ObjectMeta.Labels[model.DeployedByLabel], "expected template metadata label to be set") + + found := false + for _, c := range spec.Template.Spec.Containers { + if c.Name == "diverted" { + found = true + break + } + } + assert.True(t, found, "expected diverted container to be added by divert driver") +} + +func TestJSONTranslatorTranslateReplicationController(t *testing.T) { + input := map[string]interface{}{ + "apiVersion": "v1", + "kind": "ReplicationController", + "metadata": map[string]interface{}{ + "name": "rc-test", + "labels": map[string]string{}, + }, + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]string{"rc": "true"}, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{}, + }, + }, + }, + } + translatorName := "test-deployer" + out := runTranslatorTest(t, input, translatorName, &fakeDivertDriver{}) + + var meta metav1.ObjectMeta + err := json.Unmarshal(out["metadata"], &meta) + assert.NoError(t, err, "failed to unmarshal metadata") + assert.Equal(t, translatorName, meta.Labels[model.DeployedByLabel], "expected metadata label to be set") + + var spec apiv1.ReplicationControllerSpec + err = json.Unmarshal(out["spec"], &spec) + assert.NoError(t, err, "failed to unmarshal replicationcontroller spec") + assert.Equal(t, translatorName, spec.Template.ObjectMeta.Labels[model.DeployedByLabel], "expected template metadata label to be set") + + found := false + for _, c := range spec.Template.Spec.Containers { + if c.Name == "diverted" { + found = true + break + } + } + assert.True(t, found, "expected diverted container to be added by divert driver") +} + +func TestJSONTranslatorTranslateReplicaSet(t *testing.T) { + input := map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "ReplicaSet", + "metadata": map[string]interface{}{ + "name": "rs-test", + "labels": map[string]string{}, + }, + "spec": map[string]interface{}{ + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]string{"rs": "true"}, + }, + "spec": map[string]interface{}{ + "containers": []interface{}{}, + }, + }, + }, + } + translatorName := "test-deployer" + out := runTranslatorTest(t, input, translatorName, &fakeDivertDriver{}) + + var meta metav1.ObjectMeta + err := json.Unmarshal(out["metadata"], &meta) + assert.NoError(t, err, "failed to unmarshal metadata") + assert.Equal(t, translatorName, meta.Labels[model.DeployedByLabel], "expected metadata label to be set") + + var spec appsv1.ReplicaSetSpec + err = json.Unmarshal(out["spec"], &spec) + assert.NoError(t, err, "failed to unmarshal replicaset spec") + assert.Equal(t, translatorName, spec.Template.ObjectMeta.Labels[model.DeployedByLabel], "expected template metadata label to be set") + + found := false + for _, c := range spec.Template.Spec.Containers { + if c.Name == "diverted" { + found = true + break + } + } + assert.True(t, found, "expected diverted container to be added by divert driver") +} + +func TestJSONTranslatorTranslateVirtualService(t *testing.T) { + input := map[string]interface{}{ + "apiVersion": "networking.istio.io/v1beta1", + "kind": "VirtualService", + "metadata": map[string]interface{}{ + "name": "vs-test", + "labels": map[string]string{}, + }, + "spec": map[string]interface{}{ + "hosts": []string{"original.example.com"}, + }, + } + translatorName := "test-deployer" + out := runTranslatorTest(t, input, translatorName, &fakeDivertDriver{}) + + var meta metav1.ObjectMeta + err := json.Unmarshal(out["metadata"], &meta) + assert.NoError(t, err, "failed to unmarshal metadata") + assert.Equal(t, translatorName, meta.Labels[model.DeployedByLabel], "expected metadata label to be set") + + var vs istioNetworkingV1beta1.VirtualService + err = json.Unmarshal(out["spec"], &vs) + assert.NoError(t, err, "failed to unmarshal virtual service spec") + expectedHosts := []string{"diverted.example.com"} + assert.True(t, reflect.DeepEqual(vs.Hosts, expectedHosts), "expected virtual service hosts to be %v, got %v", expectedHosts, vs.Hosts) +} + +func TestJSONTranslatorTranslateVirtualServiceWithoutDivertDriver(t *testing.T) { + // When no divert driver is provided, the virtual service should remain unchanged. + input := map[string]interface{}{ + "apiVersion": "networking.istio.io/v1beta1", + "kind": "VirtualService", + "metadata": map[string]interface{}{ + "name": "vs-test", + "labels": map[string]string{}, + }, + "spec": map[string]interface{}{ + "hosts": []string{"original.example.com"}, + }, + } + translatorName := "test-deployer" + out := runTranslatorTest(t, input, translatorName, nil) + + var vs istioNetworkingV1beta1.VirtualService + err := json.Unmarshal(out["spec"], &vs) + assert.NoError(t, err, "failed to unmarshal virtual service spec") + expectedHosts := []string{"original.example.com"} + assert.True(t, reflect.DeepEqual(vs.Hosts, expectedHosts), "expected virtual service hosts to remain unchanged as %v, got %v", expectedHosts, vs.Hosts) +} + +func TestJSONTranslatorMissingMetadata(t *testing.T) { + // Create an input JSON without the "metadata" field. + input := map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + // "metadata" is intentionally missing + } + b, err := json.Marshal(input) + assert.NoError(t, err, "failed to marshal input without metadata") + translator := newJSONTranslator("test-deployer", nil) + _, err = translator.Translate(b) + assert.Error(t, err, "expected an error when metadata is missing") + assert.Equal(t, "request body doesn't have metadata field", err.Error(), "unexpected error message") +} diff --git a/pkg/deployable/protobuf_translator.go b/pkg/deployable/protobuf_translator.go new file mode 100644 index 000000000000..ddb1cb23ba48 --- /dev/null +++ b/pkg/deployable/protobuf_translator.go @@ -0,0 +1,225 @@ +// Copyright 2025 The Okteto Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package deployable + +import ( + "bytes" + "fmt" + + "github.com/okteto/okteto/cmd/utils" + "github.com/okteto/okteto/pkg/divert" + "github.com/okteto/okteto/pkg/k8s/labels" + oktetoLog "github.com/okteto/okteto/pkg/log" + "github.com/okteto/okteto/pkg/model" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + apiv1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer/protobuf" + "k8s.io/kubectl/pkg/scheme" +) + +type protobufTranslator struct { + serializer *protobuf.Serializer + divertDriver divert.Driver + name string +} + +func newProtobufTranslator(name string, divertDriver divert.Driver) *protobufTranslator { + protobufSerializer := protobuf.NewSerializer(scheme.Scheme, scheme.Scheme) + return &protobufTranslator{ + name: name, + serializer: protobufSerializer, + divertDriver: divertDriver, + } +} + +func (p *protobufTranslator) Translate(b []byte) ([]byte, error) { + // Passing nil for defaultGVK and into, so the serializer infers the GVK from the data and creates a new object. + // This is necessary because the object is not known at compile time. + obj, _, err := p.serializer.Decode(b, nil, nil) + if err != nil { + oktetoLog.Infof("error unmarshalling resource body on proxy: %s", err.Error()) + return nil, fmt.Errorf("could not unmarshal resource body: %w", err) + } + + if err := p.translateMetadata(obj); err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := p.serializer.Encode(obj, &buf); err != nil { + return nil, fmt.Errorf("could not encode resource: %w", err) + } + + switch obj.GetObjectKind().GroupVersionKind().Kind { + case "Deployment": + if err := p.translateDeploymentSpec(obj); err != nil { + return nil, err + } + case "StatefulSet": + if err := p.translateStatefulSetSpec(obj); err != nil { + return nil, err + } + case "Job": + if err := p.translateJobSpec(obj); err != nil { + return nil, err + } + case "CronJob": + if err := p.translateCronJobSpec(obj); err != nil { + return nil, err + } + case "DaemonSet": + if err := p.translateDaemonSetSpec(obj); err != nil { + return nil, err + } + case "ReplicationController": + if err := p.translateReplicationControllerSpec(obj); err != nil { + return nil, err + } + case "ReplicaSet": + if err := p.translateReplicaSetSpec(obj); err != nil { + return nil, err + } + + } + + return buf.Bytes(), nil +} + +func (p *protobufTranslator) translateMetadata(obj runtime.Object) error { + metaObj, err := meta.Accessor(obj) + if err != nil { + return fmt.Errorf("could not access object metadata: %w", err) + } + + // Update labels directly instead of using labels.SetInMetadata. + currentLabels := metaObj.GetLabels() + if currentLabels == nil { + currentLabels = make(map[string]string) + } + currentLabels[model.DeployedByLabel] = p.name + metaObj.SetLabels(currentLabels) + + // Update annotations directly. + annotations := metaObj.GetAnnotations() + if annotations == nil { + annotations = make(map[string]string) + } + if utils.IsOktetoRepo() { + annotations[model.OktetoSampleAnnotation] = "true" + } + metaObj.SetAnnotations(annotations) + + return nil +} + +func (p *protobufTranslator) translateDeploymentSpec(obj runtime.Object) error { + deployment, ok := obj.(*appsv1.Deployment) + if !ok { + return fmt.Errorf("expected *appsv1.Deployment, got %T", obj) + } + + labels.SetInMetadata(&deployment.Spec.Template.ObjectMeta, model.DeployedByLabel, p.name) + + deployment.Spec.Template.Spec = p.applyDivertToPod(deployment.Spec.Template.Spec) + + return nil +} + +func (p *protobufTranslator) translateStatefulSetSpec(obj runtime.Object) error { + sts, ok := obj.(*appsv1.StatefulSet) + if !ok { + return fmt.Errorf("expected *appsv1.Statefulset, got %T", obj) + } + + labels.SetInMetadata(&sts.Spec.Template.ObjectMeta, model.DeployedByLabel, p.name) + + sts.Spec.Template.Spec = p.applyDivertToPod(sts.Spec.Template.Spec) + + return nil +} + +func (p *protobufTranslator) translateJobSpec(obj runtime.Object) error { + job, ok := obj.(*batchv1.Job) + if !ok { + return fmt.Errorf("expected *batchv1.Job, got %T", obj) + } + + labels.SetInMetadata(&job.Spec.Template.ObjectMeta, model.DeployedByLabel, p.name) + + job.Spec.Template.Spec = p.applyDivertToPod(job.Spec.Template.Spec) + + return nil +} + +func (p *protobufTranslator) translateCronJobSpec(obj runtime.Object) error { + cronJob, ok := obj.(*batchv1.CronJob) + if !ok { + return fmt.Errorf("expected *batchv1.CronJob, got %T", obj) + } + + labels.SetInMetadata(&cronJob.Spec.JobTemplate.Spec.Template.ObjectMeta, model.DeployedByLabel, p.name) + + cronJob.Spec.JobTemplate.Spec.Template.Spec = p.applyDivertToPod(cronJob.Spec.JobTemplate.Spec.Template.Spec) + + return nil +} + +func (p *protobufTranslator) translateDaemonSetSpec(obj runtime.Object) error { + daemonSet, ok := obj.(*appsv1.DaemonSet) + if !ok { + return fmt.Errorf("expected *appsv1.DaemonSet, got %T", obj) + } + + labels.SetInMetadata(&daemonSet.Spec.Template.ObjectMeta, model.DeployedByLabel, p.name) + + daemonSet.Spec.Template.Spec = p.applyDivertToPod(daemonSet.Spec.Template.Spec) + + return nil +} + +func (p *protobufTranslator) translateReplicationControllerSpec(obj runtime.Object) error { + replicationController, ok := obj.(*apiv1.ReplicationController) + if !ok { + return fmt.Errorf("expected *apiv1.ReplicationController, got %T", obj) + } + + labels.SetInMetadata(&replicationController.Spec.Template.ObjectMeta, model.DeployedByLabel, p.name) + + replicationController.Spec.Template.Spec = p.applyDivertToPod(replicationController.Spec.Template.Spec) + + return nil +} + +func (p *protobufTranslator) translateReplicaSetSpec(obj runtime.Object) error { + replicaSet, ok := obj.(*appsv1.ReplicaSet) + if !ok { + return fmt.Errorf("expected *appsv1.ReplicaSet, got %T", obj) + } + + labels.SetInMetadata(&replicaSet.Spec.Template.ObjectMeta, model.DeployedByLabel, p.name) + + replicaSet.Spec.Template.Spec = p.applyDivertToPod(replicaSet.Spec.Template.Spec) + + return nil +} + +func (p *protobufTranslator) applyDivertToPod(podSpec apiv1.PodSpec) apiv1.PodSpec { + if p.divertDriver == nil { + return podSpec + } + return p.divertDriver.UpdatePod(podSpec) +} diff --git a/pkg/deployable/protobuf_translator_test.go b/pkg/deployable/protobuf_translator_test.go new file mode 100644 index 000000000000..e170b6e90df3 --- /dev/null +++ b/pkg/deployable/protobuf_translator_test.go @@ -0,0 +1,458 @@ +// Copyright 2025 The Okteto Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package deployable + +import ( + "bytes" + "testing" + + "github.com/okteto/okteto/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + batchv1 "k8s.io/api/batch/v1" + apiv1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer/protobuf" + "k8s.io/kubectl/pkg/scheme" +) + +// fake object that does not support metadata. +type noMetaObject struct{} + +func (n *noMetaObject) GetObjectKind() schema.ObjectKind { + return schema.EmptyObjectKind +} + +func (n *noMetaObject) DeepCopyObject() runtime.Object { + return n +} + +func TestProtobufTranslator_Translate_Success(t *testing.T) { + // Create a sample Pod with some existing labels. + pod := &apiv1.Pod{ + TypeMeta: metav1.TypeMeta{ + Kind: "Pod", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Labels: map[string]string{ + "existing": "value", + }, + }, + Spec: apiv1.PodSpec{ + Containers: []apiv1.Container{ + {Name: "container1", Image: "nginx"}, + }, + }, + } + + // Create a protobuf serializer (using the same scheme as the translator). + pbSerializer := protobuf.NewSerializer(scheme.Scheme, scheme.Scheme) + var buf bytes.Buffer + err := pbSerializer.Encode(pod, &buf) + require.NoError(t, err, "failed to encode pod to protobuf") + inputBytes := buf.Bytes() + + translatorName := "test-deployer" + translator := newProtobufTranslator(translatorName, nil) + outputBytes, err := translator.Translate(inputBytes) + require.NoError(t, err, "Translate returned an error") + require.NotNil(t, outputBytes, "expected non-nil output bytes") + + decodedObj, _, err := pbSerializer.Decode(outputBytes, nil, nil) + require.NoError(t, err, "failed to decode output bytes") + + podOut, ok := decodedObj.(*apiv1.Pod) + require.True(t, ok, "decoded object is not a Pod") + + labels := podOut.GetLabels() + require.Equal(t, translatorName, labels[model.DeployedByLabel], "expected deployed-by label to be set") + + annotations := podOut.GetAnnotations() + require.NotNil(t, annotations, "annotations should not be nil after translation") + assert.Equal(t, "true", annotations[model.OktetoSampleAnnotation], "expected okteto sample annotation to be set") +} + +func TestProtobufTranslator_InvalidInput(t *testing.T) { + invalidBytes := []byte("this is not valid protobuf data") + translator := newProtobufTranslator("test-deployer", nil) + outputBytes, err := translator.Translate(invalidBytes) + assert.Error(t, err, "Translate should not return an error for invalid input") + assert.Nil(t, outputBytes, "expected output bytes to be nil for invalid input") +} + +func TestProtobufTranslator_translateMetadata_NoMetadata(t *testing.T) { + translator := newProtobufTranslator("test-deployer", nil) + obj := &noMetaObject{} + err := translator.translateMetadata(obj) + assert.Error(t, err, "expected error when object has no metadata") +} + +func TestTranslateDeploymentSpec(t *testing.T) { + tests := []struct { + name string + obj runtime.Object + expectError bool + }{ + { + name: "valid deployment", + obj: &appsv1.Deployment{ + Spec: appsv1.DeploymentSpec{ + Template: apiv1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{}, // no labels set yet + Spec: apiv1.PodSpec{Containers: []apiv1.Container{}}, + }, + }, + }, + expectError: false, + }, + { + name: "invalid type", + obj: &appsv1.StatefulSet{}, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + translator := &protobufTranslator{ + name: "test-name", + divertDriver: &fakeDivertDriver{}, + } + + err := translator.translateDeploymentSpec(tc.obj) + if tc.expectError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + dep := tc.obj.(*appsv1.Deployment) + label, exists := dep.Spec.Template.ObjectMeta.Labels[model.DeployedByLabel] + assert.True(t, exists, "expected label %q to exist", model.DeployedByLabel) + assert.Equal(t, translator.name, label, "unexpected label value") + containers := dep.Spec.Template.Spec.Containers + assert.Len(t, containers, 1) + assert.Equal(t, "diverted", containers[0].Name) + }) + } +} + +func TestTranslateStatefulSetSpec(t *testing.T) { + tests := []struct { + name string + obj runtime.Object + expectError bool + }{ + { + name: "valid statefulset", + obj: &appsv1.StatefulSet{ + Spec: appsv1.StatefulSetSpec{ + Template: apiv1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{}, + Spec: apiv1.PodSpec{Containers: []apiv1.Container{}}, + }, + }, + }, + expectError: false, + }, + { + name: "invalid type", + obj: &appsv1.Deployment{}, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + translator := &protobufTranslator{ + name: "test-name", + divertDriver: &fakeDivertDriver{}, + } + + err := translator.translateStatefulSetSpec(tc.obj) + if tc.expectError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + sts := tc.obj.(*appsv1.StatefulSet) + label, exists := sts.Spec.Template.ObjectMeta.Labels[model.DeployedByLabel] + assert.True(t, exists, "expected label %q to exist", model.DeployedByLabel) + assert.Equal(t, translator.name, label) + containers := sts.Spec.Template.Spec.Containers + assert.Len(t, containers, 1) + assert.Equal(t, "diverted", containers[0].Name) + }) + } +} + +func TestTranslateJobSpec(t *testing.T) { + tests := []struct { + name string + obj runtime.Object + expectError bool + }{ + { + name: "valid job", + obj: &batchv1.Job{ + Spec: batchv1.JobSpec{ + Template: apiv1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{}, + Spec: apiv1.PodSpec{Containers: []apiv1.Container{}}, + }, + }, + }, + expectError: false, + }, + { + name: "invalid type", + obj: &appsv1.Deployment{}, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + translator := &protobufTranslator{ + name: "test-name", + divertDriver: &fakeDivertDriver{}, + } + + err := translator.translateJobSpec(tc.obj) + if tc.expectError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + job := tc.obj.(*batchv1.Job) + label, exists := job.Spec.Template.ObjectMeta.Labels[model.DeployedByLabel] + assert.True(t, exists, "expected label %q to exist", model.DeployedByLabel) + assert.Equal(t, translator.name, label) + containers := job.Spec.Template.Spec.Containers + assert.Len(t, containers, 1) + assert.Equal(t, "diverted", containers[0].Name) + }) + } +} + +func TestTranslateCronJobSpec(t *testing.T) { + tests := []struct { + name string + obj runtime.Object + expectError bool + }{ + { + name: "valid cronjob", + obj: &batchv1.CronJob{ + Spec: batchv1.CronJobSpec{ + JobTemplate: batchv1.JobTemplateSpec{ + Spec: batchv1.JobSpec{ + Template: apiv1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{}, + Spec: apiv1.PodSpec{Containers: []apiv1.Container{}}, + }, + }, + }, + }, + }, + expectError: false, + }, + { + name: "invalid type", + obj: &appsv1.Deployment{}, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + translator := &protobufTranslator{ + name: "test-name", + divertDriver: &fakeDivertDriver{}, + } + + err := translator.translateCronJobSpec(tc.obj) + if tc.expectError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + cron := tc.obj.(*batchv1.CronJob) + label, exists := cron.Spec.JobTemplate.Spec.Template.ObjectMeta.Labels[model.DeployedByLabel] + assert.True(t, exists, "expected label %q to exist", model.DeployedByLabel) + assert.Equal(t, translator.name, label) + containers := cron.Spec.JobTemplate.Spec.Template.Spec.Containers + assert.Len(t, containers, 1) + assert.Equal(t, "diverted", containers[0].Name) + }) + } +} + +func TestTranslateDaemonSetSpec(t *testing.T) { + tests := []struct { + name string + obj runtime.Object + expectError bool + }{ + { + name: "valid daemonset", + obj: &appsv1.DaemonSet{ + Spec: appsv1.DaemonSetSpec{ + Template: apiv1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{}, + Spec: apiv1.PodSpec{Containers: []apiv1.Container{}}, + }, + }, + }, + expectError: false, + }, + { + name: "invalid type", + obj: &appsv1.Deployment{}, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + translator := &protobufTranslator{ + name: "test-name", + divertDriver: &fakeDivertDriver{}, + } + + err := translator.translateDaemonSetSpec(tc.obj) + if tc.expectError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + ds := tc.obj.(*appsv1.DaemonSet) + label, exists := ds.Spec.Template.ObjectMeta.Labels[model.DeployedByLabel] + assert.True(t, exists, "expected label %q to exist", model.DeployedByLabel) + assert.Equal(t, translator.name, label) + containers := ds.Spec.Template.Spec.Containers + assert.Len(t, containers, 1) + assert.Equal(t, "diverted", containers[0].Name) + }) + } +} + +func TestTranslateReplicationControllerSpec(t *testing.T) { + tests := []struct { + name string + obj runtime.Object + expectError bool + }{ + { + name: "valid replicationcontroller", + obj: &apiv1.ReplicationController{ + Spec: apiv1.ReplicationControllerSpec{ + Template: &apiv1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{}, + Spec: apiv1.PodSpec{Containers: []apiv1.Container{}}, + }, + }, + }, + expectError: false, + }, + { + name: "invalid type", + obj: &appsv1.Deployment{}, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + translator := &protobufTranslator{ + name: "test-name", + divertDriver: &fakeDivertDriver{}, + } + + err := translator.translateReplicationControllerSpec(tc.obj) + if tc.expectError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + rc := tc.obj.(*apiv1.ReplicationController) + label, exists := rc.Spec.Template.ObjectMeta.Labels[model.DeployedByLabel] + assert.True(t, exists, "expected label %q to exist", model.DeployedByLabel) + assert.Equal(t, translator.name, label) + containers := rc.Spec.Template.Spec.Containers + assert.Len(t, containers, 1) + assert.Equal(t, "diverted", containers[0].Name) + }) + } +} + +func TestTranslateReplicaSetSpec(t *testing.T) { + tests := []struct { + name string + obj runtime.Object + expectError bool + }{ + { + name: "valid replicaset", + obj: &appsv1.ReplicaSet{ + Spec: appsv1.ReplicaSetSpec{ + Template: apiv1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{}, + Spec: apiv1.PodSpec{Containers: []apiv1.Container{}}, + }, + }, + }, + expectError: false, + }, + { + name: "invalid type", + obj: &appsv1.Deployment{}, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + translator := &protobufTranslator{ + name: "test-name", + divertDriver: &fakeDivertDriver{}, + } + + err := translator.translateReplicaSetSpec(tc.obj) + if tc.expectError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + + rs := tc.obj.(*appsv1.ReplicaSet) + label, exists := rs.Spec.Template.ObjectMeta.Labels[model.DeployedByLabel] + assert.True(t, exists, "expected label %q to exist", model.DeployedByLabel) + assert.Equal(t, translator.name, label) + containers := rs.Spec.Template.Spec.Containers + assert.Len(t, containers, 1) + assert.Equal(t, "diverted", containers[0].Name) + }) + } +} diff --git a/pkg/deployable/proxy.go b/pkg/deployable/proxy.go index 5a5865a01e88..7b58d74791a8 100644 --- a/pkg/deployable/proxy.go +++ b/pkg/deployable/proxy.go @@ -17,7 +17,6 @@ import ( "bytes" "context" "crypto/tls" - "encoding/json" "fmt" "io" "net" @@ -30,18 +29,11 @@ import ( "syscall" "github.com/google/uuid" - "github.com/okteto/okteto/cmd/utils" + "github.com/okteto/okteto/pkg/analytics" "github.com/okteto/okteto/pkg/divert" oktetoErrors "github.com/okteto/okteto/pkg/errors" - "github.com/okteto/okteto/pkg/k8s/labels" oktetoLog "github.com/okteto/okteto/pkg/log" - "github.com/okteto/okteto/pkg/model" "github.com/okteto/okteto/pkg/okteto" - istioNetworkingV1beta1 "istio.io/api/networking/v1beta1" - appsv1 "k8s.io/api/apps/v1" - batchv1 "k8s.io/api/batch/v1" - apiv1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/rest" ) @@ -64,7 +56,13 @@ type Proxy struct { type proxyHandler struct { DivertDriver divert.Driver // Name is sanitized version of the pipeline name - Name string + Name string + translators map[string]translator + analyticsTracker *analytics.Tracker +} + +type translator interface { + Translate([]byte) ([]byte, error) } // NewProxy creates a new proxy @@ -92,7 +90,9 @@ func NewProxy(kubeconfig KubeConfigHandler, portGetter PortGetterFunc) (*Proxy, return nil, err } - ph := &proxyHandler{} + ph := &proxyHandler{ + analyticsTracker: analytics.NewAnalyticsTracker(), + } handler, err := ph.getProxyHandler(sessionToken, clusterConfig) if err != nil { oktetoLog.Errorf("could not configure local proxy: %s", err) @@ -182,6 +182,13 @@ func (p *Proxy) SetDivert(driver divert.Driver) { p.proxyHandler.SetDivert(driver) } +func (p *Proxy) InitTranslator() { + p.proxyHandler.translators = map[string]translator{ + "application/json": newJSONTranslator(p.proxyHandler.Name, p.proxyHandler.DivertDriver), + "application/vnd.kubernetes.protobuf": newProtobufTranslator(p.proxyHandler.Name, p.proxyHandler.DivertDriver), + } +} + func (ph *proxyHandler) getProxyHandler(token string, clusterConfig *rest.Config) (http.Handler, error) { // By default we don't disable HTTP/2 trans, err := newProtocolTransport(clusterConfig, false) @@ -246,8 +253,15 @@ func (ph *proxyHandler) getProxyHandler(token string, clusterConfig *rest.Config reverseProxy.ServeHTTP(rw, r) return } - - b, err = ph.translateBody(b) + var translator translator + if v, ok := ph.translators[r.Header.Get("Content-Type")]; ok { + translator = v + } else { + oktetoLog.Infof("unsupported content type: %s", r.Header.Get("Content-Type")) + go ph.analyticsTracker.TrackUnsupportedContentType(r.Header.Get("Content-Type")) + translator = ph.translators["application/json"] + } + b, err = translator.Translate(b) if err != nil { oktetoLog.Info(err) rw.WriteHeader(http.StatusInternalServerError) @@ -275,230 +289,6 @@ func (ph *proxyHandler) SetDivert(driver divert.Driver) { ph.DivertDriver = driver } -func (ph *proxyHandler) translateBody(b []byte) ([]byte, error) { - var body map[string]json.RawMessage - if err := json.Unmarshal(b, &body); err != nil { - oktetoLog.Infof("error unmarshalling resource body on proxy: %s", err.Error()) - return nil, nil - } - - if err := ph.translateMetadata(body); err != nil { - return nil, err - } - - var typeMeta metav1.TypeMeta - if err := json.Unmarshal(b, &typeMeta); err != nil { - oktetoLog.Infof("error unmarshalling typemeta on proxy: %s", err.Error()) - return nil, nil - } - - switch typeMeta.Kind { - case "Deployment": - if err := ph.translateDeploymentSpec(body); err != nil { - return nil, err - } - case "StatefulSet": - if err := ph.translateStatefulSetSpec(body); err != nil { - return nil, err - } - case "Job": - if err := ph.translateJobSpec(body); err != nil { - return nil, err - } - case "CronJob": - if err := ph.translateCronJobSpec(body); err != nil { - return nil, err - } - case "DaemonSet": - if err := ph.translateDaemonSetSpec(body); err != nil { - return nil, err - } - case "ReplicationController": - if err := ph.translateReplicationControllerSpec(body); err != nil { - return nil, err - } - case "ReplicaSet": - if err := ph.translateReplicaSetSpec(body); err != nil { - return nil, err - } - case "VirtualService": - if err := ph.translateVirtualServiceSpec(body); err != nil { - return nil, err - } - } - - return json.Marshal(body) -} - -func (ph *proxyHandler) translateMetadata(body map[string]json.RawMessage) error { - m, ok := body["metadata"] - if !ok { - return fmt.Errorf("request body doesn't have metadata field") - } - - var metadata metav1.ObjectMeta - if err := json.Unmarshal(m, &metadata); err != nil { - oktetoLog.Infof("error unmarshalling objectmeta on proxy: %s", err.Error()) - return nil - } - - labels.SetInMetadata(&metadata, model.DeployedByLabel, ph.Name) - - if metadata.Annotations == nil { - metadata.Annotations = map[string]string{} - } - if utils.IsOktetoRepo() { - metadata.Annotations[model.OktetoSampleAnnotation] = "true" - } - - metadataAsByte, err := json.Marshal(metadata) - if err != nil { - return fmt.Errorf("could not process resource's metadata: %w", err) - } - - body["metadata"] = metadataAsByte - - return nil -} - -func (ph *proxyHandler) translateDeploymentSpec(body map[string]json.RawMessage) error { - var spec appsv1.DeploymentSpec - if err := json.Unmarshal(body["spec"], &spec); err != nil { - oktetoLog.Infof("error unmarshalling deployment spec on proxy: %s", err.Error()) - return nil - } - labels.SetInMetadata(&spec.Template.ObjectMeta, model.DeployedByLabel, ph.Name) - spec.Template.Spec = ph.applyDivertToPod(spec.Template.Spec) - specAsByte, err := json.Marshal(spec) - if err != nil { - return fmt.Errorf("could not process deployment's spec: %w", err) - } - body["spec"] = specAsByte - return nil -} - -func (ph *proxyHandler) translateStatefulSetSpec(body map[string]json.RawMessage) error { - var spec appsv1.StatefulSetSpec - if err := json.Unmarshal(body["spec"], &spec); err != nil { - oktetoLog.Infof("error unmarshalling statefulset spec on proxy: %s", err.Error()) - return nil - } - labels.SetInMetadata(&spec.Template.ObjectMeta, model.DeployedByLabel, ph.Name) - spec.Template.Spec = ph.applyDivertToPod(spec.Template.Spec) - specAsByte, err := json.Marshal(spec) - if err != nil { - return fmt.Errorf("could not process statefulset's spec: %w", err) - } - body["spec"] = specAsByte - return nil -} - -func (ph *proxyHandler) translateJobSpec(body map[string]json.RawMessage) error { - var spec batchv1.JobSpec - if err := json.Unmarshal(body["spec"], &spec); err != nil { - oktetoLog.Infof("error unmarshalling job spec on proxy: %s", err.Error()) - return nil - } - labels.SetInMetadata(&spec.Template.ObjectMeta, model.DeployedByLabel, ph.Name) - spec.Template.Spec = ph.applyDivertToPod(spec.Template.Spec) - specAsByte, err := json.Marshal(spec) - if err != nil { - return fmt.Errorf("could not process job's spec: %w", err) - } - body["spec"] = specAsByte - return nil -} - -func (ph *proxyHandler) translateCronJobSpec(body map[string]json.RawMessage) error { - var spec batchv1.CronJobSpec - if err := json.Unmarshal(body["spec"], &spec); err != nil { - oktetoLog.Infof("error unmarshalling cronjob spec on proxy: %s", err.Error()) - return nil - } - labels.SetInMetadata(&spec.JobTemplate.Spec.Template.ObjectMeta, model.DeployedByLabel, ph.Name) - spec.JobTemplate.Spec.Template.Spec = ph.applyDivertToPod(spec.JobTemplate.Spec.Template.Spec) - specAsByte, err := json.Marshal(spec) - if err != nil { - return fmt.Errorf("could not process cronjob's spec: %w", err) - } - body["spec"] = specAsByte - return nil -} - -func (ph *proxyHandler) translateDaemonSetSpec(body map[string]json.RawMessage) error { - var spec appsv1.DaemonSetSpec - if err := json.Unmarshal(body["spec"], &spec); err != nil { - oktetoLog.Infof("error unmarshalling daemonset spec on proxy: %s", err.Error()) - return nil - } - labels.SetInMetadata(&spec.Template.ObjectMeta, model.DeployedByLabel, ph.Name) - spec.Template.Spec = ph.applyDivertToPod(spec.Template.Spec) - specAsByte, err := json.Marshal(spec) - if err != nil { - return fmt.Errorf("could not process daemonset's spec: %w", err) - } - body["spec"] = specAsByte - return nil -} - -func (ph *proxyHandler) translateReplicationControllerSpec(body map[string]json.RawMessage) error { - var spec apiv1.ReplicationControllerSpec - if err := json.Unmarshal(body["spec"], &spec); err != nil { - oktetoLog.Infof("error unmarshalling replicationcontroller on proxy: %s", err.Error()) - return nil - } - labels.SetInMetadata(&spec.Template.ObjectMeta, model.DeployedByLabel, ph.Name) - spec.Template.Spec = ph.applyDivertToPod(spec.Template.Spec) - specAsByte, err := json.Marshal(spec) - if err != nil { - return fmt.Errorf("could not process replicationcontroller's spec: %w", err) - } - body["spec"] = specAsByte - return nil -} - -func (ph *proxyHandler) translateReplicaSetSpec(body map[string]json.RawMessage) error { - var spec appsv1.ReplicaSetSpec - if err := json.Unmarshal(body["spec"], &spec); err != nil { - oktetoLog.Infof("error unmarshalling replicaset on proxy: %s", err.Error()) - return nil - } - labels.SetInMetadata(&spec.Template.ObjectMeta, model.DeployedByLabel, ph.Name) - spec.Template.Spec = ph.applyDivertToPod(spec.Template.Spec) - specAsByte, err := json.Marshal(spec) - if err != nil { - return fmt.Errorf("could not process replicaset's spec: %w", err) - } - body["spec"] = specAsByte - return nil -} - -func (ph *proxyHandler) applyDivertToPod(podSpec apiv1.PodSpec) apiv1.PodSpec { - if ph.DivertDriver == nil { - return podSpec - } - return ph.DivertDriver.UpdatePod(podSpec) -} - -func (ph *proxyHandler) translateVirtualServiceSpec(body map[string]json.RawMessage) error { - if ph.DivertDriver == nil { - return nil - } - - var spec *istioNetworkingV1beta1.VirtualService - if err := json.Unmarshal(body["spec"], &spec); err != nil { - oktetoLog.Infof("error unmarshalling replicaset on proxy: %s", err.Error()) - return nil - } - ph.DivertDriver.UpdateVirtualService(spec) - specAsByte, err := json.Marshal(spec) - if err != nil { - return fmt.Errorf("could not process virtual service's spec: %w", err) - } - body["spec"] = specAsByte - return nil -} - func newProtocolTransport(clusterConfig *rest.Config, disableHTTP2 bool) (http.RoundTripper, error) { copiedConfig := &rest.Config{} *copiedConfig = *clusterConfig diff --git a/pkg/deployable/proxy_test.go b/pkg/deployable/proxy_test.go index 288e4316e591..1d7f4ad98924 100644 --- a/pkg/deployable/proxy_test.go +++ b/pkg/deployable/proxy_test.go @@ -14,7 +14,6 @@ package deployable import ( - "encoding/json" "net" "testing" @@ -23,10 +22,6 @@ import ( "k8s.io/client-go/rest" ) -var ( - ph = &proxyHandler{} -) - type fakeKubeConfig struct { config *rest.Config errOnModify error @@ -44,55 +39,6 @@ func (fc *fakeKubeConfig) Modify(_ int, _, _ string) error { return fc.errOnModify } -func Test_TranslateInvalidResourceBody(t *testing.T) { - t.Parallel() - var tests = []struct { - name string - body []byte - }{ - { - name: "null body json.RawMessage", - body: []byte(``), - }, - { - name: "correct body json.RawMessage", - body: []byte(`{"kind":"Secret","apiVersion":"v1","metadata":{"name":"sh.helm.release.v1.movies.v6"},"type":"helm.sh/release.v1"}`), - }, - { - name: "incorrect body typemeta", - body: []byte(`{"kind": {"key": "value"},"apiVersion":"v1","metadata":{"name":"sh.helm.release.v1.movies.v6"},"type":"helm.sh/release.v1"}`), - }, - } - - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - _, err := ph.translateBody(tt.body) - assert.NoError(t, err) - }) - } -} - -func Test_TranslateInvalidResourceSpec(t *testing.T) { - invalidResourceSpec := map[string]json.RawMessage{ - "spec": []byte(`{"selector": "invalid value"}`), - } - assert.NoError(t, ph.translateDeploymentSpec(invalidResourceSpec)) - assert.NoError(t, ph.translateStatefulSetSpec(invalidResourceSpec)) - assert.NoError(t, ph.translateReplicationControllerSpec(invalidResourceSpec)) - assert.NoError(t, ph.translateReplicaSetSpec(invalidResourceSpec)) - assert.NoError(t, ph.translateDaemonSetSpec(invalidResourceSpec)) - - assert.NoError(t, ph.translateJobSpec(map[string]json.RawMessage{ - "spec": []byte(`{"parallelism": "invalid value"}`), - })) - - assert.NoError(t, ph.translateCronJobSpec(map[string]json.RawMessage{ - "spec": []byte(`{"schedule": 1}`), - })) -} - func Test_NewProxy(t *testing.T) { dnsErr := &net.DNSError{ IsNotFound: true,