diff --git a/go.mod b/go.mod index fdb27ce7..955129e4 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ replace ( require ( github.com/adrg/xdg v0.4.0 github.com/golang/mock v1.6.0 + github.com/golang/protobuf v1.5.3 github.com/gorilla/mux v1.8.0 github.com/gorilla/websocket v1.5.0 github.com/pborman/uuid v1.2.1 @@ -28,17 +29,20 @@ require ( github.com/urfave/cli v1.22.14 github.com/urfave/cli/v2 v2.25.7 golang.org/x/sync v0.5.0 + helm.sh/helm/v3 v3.11.0 k8s.io/api v0.28.6 k8s.io/apiextensions-apiserver v0.28.6 k8s.io/apimachinery v0.28.6 k8s.io/apiserver v0.28.6 k8s.io/client-go v12.0.0+incompatible + k8s.io/helm v2.17.0+incompatible k8s.io/klog v1.0.0 k8s.io/kube-aggregator v0.28.6 k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9 ) require ( + github.com/Masterminds/semver/v3 v3.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect @@ -55,7 +59,6 @@ require ( github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.22.3 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/protobuf v1.5.3 // indirect github.com/google/gnostic-models v0.6.8 // indirect github.com/google/go-cmp v0.5.9 // indirect github.com/google/gofuzz v1.2.0 // indirect diff --git a/go.sum b/go.sum index c18e67b1..c5a40a93 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,8 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= +github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= @@ -792,6 +794,8 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +helm.sh/helm/v3 v3.11.0 h1:F+peaCQYbycY1FIqIQ6dAortHd/VzV5FkhMciv4Kf+c= +helm.sh/helm/v3 v3.11.0/go.mod h1:z/Bu/BylToGno/6dtNGuSmjRqxKq5gaH+FU0BPO+AQ8= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= @@ -812,6 +816,8 @@ k8s.io/component-base v0.28.6 h1:G4T8VrcQ7xZou3by/fY5NU5mfxOBlWaivS2lPrEltAo= k8s.io/component-base v0.28.6/go.mod h1:Dg62OOG3ALu2P4nAG00UdsuHoNLQJ5VsUZKQlLDcS+E= k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0= k8s.io/gengo v0.0.0-20210813121822-485abfe95c7c/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= +k8s.io/helm v2.17.0+incompatible h1:Bpn6o1wKLYqKM3+Osh8e+1/K2g/GsQJ4F4yNF2+deao= +k8s.io/helm v2.17.0+incompatible/go.mod h1:LZzlS4LQBHfciFOurYBFkCMTaZ0D1l+p0teMg7TSULI= k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk= k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8= k8s.io/klog v1.0.0/go.mod h1:4Bi6QPql/J/LkTDqv7R/cd3hPo4k2DG6Ptcz060Ez5I= diff --git a/pkg/resources/formatters/formatter.go b/pkg/resources/formatters/formatter.go index 212b4fcd..5fc6d287 100644 --- a/pkg/resources/formatters/formatter.go +++ b/pkg/resources/formatters/formatter.go @@ -1,17 +1,59 @@ package formatters import ( + "bytes" + "compress/gzip" + "encoding/base64" + "encoding/json" + "io" + + "github.com/golang/protobuf/proto" + "github.com/pkg/errors" "github.com/rancher/apiserver/pkg/types" "github.com/rancher/norman/types/convert" + "github.com/rancher/wrangler/v2/pkg/data" + "github.com/sirupsen/logrus" + "helm.sh/helm/v3/pkg/release" + rspb "k8s.io/helm/pkg/proto/hapi/release" ) -func DropHelmData(request *types.APIRequest, resource *types.RawResource) { - data := resource.APIObject.Data() - if data.String("metadata", "labels", "owner") == "helm" || - data.String("metadata", "labels", "OWNER") == "TILLER" { - if data.String("data", "release") != "" { - delete(data.Map("data"), "release") +var ( + ErrNotHelmRelease = errors.New("not helm release") // error for when it's not a helm release + magicGzip = []byte{0x1f, 0x8b, 0x08} // gzip magic header +) + +func HandleHelmData(request *types.APIRequest, resource *types.RawResource) { + objData := resource.APIObject.Data() + if q := request.Query.Get("includeHelmData"); q == "true" { + var helmReleaseData string + if resource.Type == "secret" { + b, err := base64.StdEncoding.DecodeString(objData.String("data", "release")) + if err != nil { + return + } + helmReleaseData = string(b) + } else { + helmReleaseData = objData.String("data", "release") } + if objData.String("metadata", "labels", "owner") == "helm" { + rl, err := decodeHelm3(helmReleaseData) + if err != nil { + logrus.Errorf("Failed to decode helm3 release data: %v", err) + return + } + objData.SetNested(rl, "data", "release") + } + if objData.String("metadata", "labels", "OWNER") == "TILLER" { + rl, err := decodeHelm2(helmReleaseData) + if err != nil { + logrus.Errorf("Failed to decode helm2 release data: %v", err) + return + } + objData.SetNested(rl, "data", "release") + } + + } else { + DropHelmData(objData) } } @@ -22,3 +64,78 @@ func Pod(request *types.APIRequest, resource *types.RawResource) { data.SetNested(convert.LowerTitle(fields[2]), "metadata", "state", "name") } } + +// decodeHelm3 receives a helm3 release data string, decodes the string data using the standard base64 library +// and unmarshals the data into release.Release struct to return it. +func decodeHelm3(data string) (*release.Release, error) { + b, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return nil, err + } + + // Data is too small to be helm 3 release object + if len(b) <= 3 { + return nil, ErrNotHelmRelease + } + + // For backwards compatibility with releases that were stored before + // compression was introduced we skip decompression if the + // gzip magic header is not found + if bytes.Equal(b[0:3], magicGzip) { + r, err := gzip.NewReader(bytes.NewReader(b)) + if err != nil { + return nil, err + } + b2, err := io.ReadAll(r) + if err != nil { + return nil, err + } + b = b2 + } + + var rls release.Release + // unmarshal release object bytes + if err := json.Unmarshal(b, &rls); err != nil { + return nil, err + } + return &rls, nil +} + +// decodeHelm2 receives a helm2 release data and returns the corresponding helm2 release proto struct +func decodeHelm2(data string) (*rspb.Release, error) { + b, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return nil, err + } + + // For backwards compatibility with releases that were stored before + // compression was introduced we skip decompression if the + // gzip magic header is not found + if bytes.Equal(b[0:3], magicGzip) { + r, err := gzip.NewReader(bytes.NewReader(b)) + if err != nil { + return nil, err + } + b2, err := io.ReadAll(r) + if err != nil { + return nil, err + } + b = b2 + } + + var rls rspb.Release + // unmarshal protobuf bytes + if err := proto.Unmarshal(b, &rls); err != nil { + return nil, err + } + return &rls, nil +} + +func DropHelmData(data data.Object) { + if data.String("metadata", "labels", "owner") == "helm" || + data.String("metadata", "labels", "OWNER") == "TILLER" { + if data.String("data", "release") != "" { + delete(data.Map("data"), "release") + } + } +} diff --git a/pkg/resources/formatters/formatter_test.go b/pkg/resources/formatters/formatter_test.go new file mode 100644 index 00000000..7bf045b2 --- /dev/null +++ b/pkg/resources/formatters/formatter_test.go @@ -0,0 +1,380 @@ +package formatters + +import ( + "bytes" + "compress/gzip" + "encoding/base64" + "encoding/json" + "github.com/golang/protobuf/proto" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/release" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + pbchart "k8s.io/helm/pkg/proto/hapi/chart" + rspb "k8s.io/helm/pkg/proto/hapi/release" + "net/url" + "testing" + + "github.com/rancher/apiserver/pkg/types" +) + +var r = release.Release{ + Name: "helmV3Release", + Chart: &chart.Chart{ + Values: map[string]interface{}{ + "key": "value", + }, + }, + Version: 1, + Namespace: "default", +} + +var rv2 = rspb.Release{ + Name: "helmV3Release", + Chart: &pbchart.Chart{ + Metadata: &pbchart.Metadata{ + Name: "chartName", + Version: "1.0.0", + }, + Values: &pbchart.Config{ + Values: map[string]*pbchart.Value{ + "key": {Value: "value"}, + }, + }, + }, + Version: 1, + Namespace: "default", +} + +func Test_HandleHelmData(t *testing.T) { + tests := []struct { + name string + resource *types.RawResource + request *types.APIRequest + want *types.RawResource + helmVersion int + }{ //helm v3 + { + name: "When receiving a SECRET resource with includeHelmData set to TRUE and owner set to HELM, it should decode the helm3 release", + resource: newSecret("helm", map[string]interface{}{ + "release": base64.StdEncoding.EncodeToString([]byte(newV3Release())), + }), + request: newRequest("true"), + want: newSecret("helm", map[string]interface{}{ + "release": &r, + }), + helmVersion: 3, + }, + { + name: "When receiving a SECRET resource with includeHelmData set to FALSE and owner set to HELM, it should drop the helm data", + resource: newSecret("helm", map[string]interface{}{ + "release": base64.StdEncoding.EncodeToString([]byte(newV3Release())), + }), + request: newRequest("false"), + want: newSecret("helm", map[string]interface{}{}), + helmVersion: 3, + }, + { + name: "When receiving a SECRET resource WITHOUT the includeHelmData query parameter and owner set to HELM, it should drop the helm data", + resource: newSecret("helm", map[string]interface{}{ + "release": base64.StdEncoding.EncodeToString([]byte(newV3Release())), + }), + request: newRequest(""), + want: newSecret("helm", map[string]interface{}{}), + helmVersion: 3, + }, + { + name: "When receiving a non-helm SECRET or CONFIGMAP resource with includeHelmData set to TRUE, it shouldn't change the resource", + resource: &types.RawResource{ + Type: "pod", + APIObject: types.APIObject{Object: &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "data": map[string]interface{}{ + "key": "value", + }, + }}}, + }, + request: newRequest("true"), + want: &types.RawResource{ + Type: "pod", + APIObject: types.APIObject{Object: &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "data": map[string]interface{}{ + "key": "value", + }, + }}}, + }, + helmVersion: 3, + }, + { + name: "When receiving a non-helm SECRET or CONFIGMAP resource with includeHelmData set to FALSE, it shouldn't change the resource", + resource: &types.RawResource{ + Type: "pod", + APIObject: types.APIObject{Object: &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "data": map[string]interface{}{ + "key": "value", + }, + }}}, + }, + request: newRequest("false"), + want: &types.RawResource{ + Type: "pod", + APIObject: types.APIObject{Object: &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "data": map[string]interface{}{ + "key": "value", + }, + }}}, + }, + helmVersion: 3, + }, + { + name: "When receiving a non-helm SECRET or CONFIGMAP resource WITHOUT the includeHelmData query parameter, it shouldn't change the resource", + resource: &types.RawResource{ + Type: "pod", + APIObject: types.APIObject{Object: &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "data": map[string]interface{}{ + "key": "value", + }, + }}}, + }, + request: newRequest(""), + want: &types.RawResource{ + Type: "pod", + APIObject: types.APIObject{Object: &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Pod", + "data": map[string]interface{}{ + "key": "value", + }, + }}}, + }, + helmVersion: 3, + }, + { + name: "When receiving a CONFIGMAP resource with includeHelmData set to TRUE and owner set to HELM, it should decode the helm3 release", + resource: newConfigMap("helm", map[string]interface{}{ + "release": newV3Release(), + }), + request: newRequest("true"), + want: newConfigMap("helm", map[string]interface{}{ + "release": &r, + }), + helmVersion: 3, + }, + { + name: "When receiving a CONFIGMAP resource with includeHelmData set to FALSE and owner set to HELM, it should drop the helm data", + resource: newConfigMap("helm", map[string]interface{}{ + "release": newV3Release(), + }), + request: newRequest("false"), + want: newConfigMap("helm", map[string]interface{}{}), + helmVersion: 3, + }, + { + name: "When receiving a CONFIGMAP resource WITHOUT the includeHelmData query parameter and owner set to HELM, it should drop the helm data", + resource: newConfigMap("helm", map[string]interface{}{ + "release": newV3Release(), + }), + request: newRequest(""), + want: newConfigMap("helm", map[string]interface{}{}), + helmVersion: 3, + }, + //helm v2 + { + name: "When receiving a SECRET resource with includeHelmData set to TRUE and owner set to TILLER, it should decode the helm2 release", + resource: newSecret("TILLER", map[string]interface{}{ + "release": base64.StdEncoding.EncodeToString([]byte(newV2Release())), + }), + request: newRequest("true"), + want: newSecret("TILLER", map[string]interface{}{ + "release": &rv2, + }), + helmVersion: 2, + }, + { + name: "When receiving a SECRET resource with includeHelmData set to FALSE and owner set to TILLER, it should drop the helm data", + resource: newSecret("TILLER", map[string]interface{}{ + "release": base64.StdEncoding.EncodeToString([]byte(newV2Release())), + }), + request: newRequest("false"), + want: newSecret("TILLER", map[string]interface{}{}), + helmVersion: 2, + }, + { + name: "When receiving a SECRET resource WITHOUT the includeHelmData query parameter and owner set to TILLER, it should drop the helm data", + resource: newSecret("TILLER", map[string]interface{}{ + "release": base64.StdEncoding.EncodeToString([]byte(newV2Release())), + }), + request: newRequest(""), + want: newSecret("TILLER", map[string]interface{}{}), + helmVersion: 2, + }, + { + name: "When receiving a CONFIGMAP resource with includeHelmData set to TRUE and owner set to TILLER, it should decode the helm2 release", + resource: newConfigMap("TILLER", map[string]interface{}{ + "release": newV2Release(), + }), + request: newRequest("true"), + want: newConfigMap("TILLER", map[string]interface{}{ + "release": &rv2, + }), + helmVersion: 2, + }, + { + name: "When receiving a CONFIGMAP resource with includeHelmData set to FALSE and owner set to TILLER, it should drop the helm data", + resource: newConfigMap("TILLER", map[string]interface{}{ + "release": newV2Release(), + }), + request: newRequest("false"), + want: newConfigMap("TILLER", map[string]interface{}{}), + helmVersion: 2, + }, + { + name: "When receiving a CONFIGMAP resource WITHOUT the includeHelmData query parameter and owner set to TILLER, it should drop the helm data", + resource: newConfigMap("TILLER", map[string]interface{}{ + "release": newV2Release(), + }), + request: newRequest(""), + want: newConfigMap("TILLER", map[string]interface{}{}), + helmVersion: 2, + }, + { + name: "[no magic gzip] When receiving a SECRET resource with includeHelmData set to TRUE and owner set to HELM, it should decode the helm3 release", + resource: newSecret("helm", map[string]interface{}{ + "release": base64.StdEncoding.EncodeToString([]byte(newV3ReleaseWithoutGzip())), + }), + request: newRequest("true"), + want: newSecret("helm", map[string]interface{}{ + "release": &r, + }), + helmVersion: 3, + }, + { + name: "[no magic gzip] When receiving a SECRET resource with includeHelmData set to TRUE and owner set to TILLER, it should decode the helm2 release", + resource: newSecret("TILLER", map[string]interface{}{ + "release": base64.StdEncoding.EncodeToString([]byte(newV2ReleaseWithoutGzip())), + }), + request: newRequest("true"), + want: newSecret("TILLER", map[string]interface{}{ + "release": &rv2, + }), + helmVersion: 2, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + HandleHelmData(tt.request, tt.resource) + if tt.helmVersion == 2 { + u, ok := tt.resource.APIObject.Object.(*unstructured.Unstructured) + assert.True(t, ok) + rl, ok := u.UnstructuredContent()["data"].(map[string]interface{})["release"] + if ok { + u, ok = tt.want.APIObject.Object.(*unstructured.Unstructured) + assert.True(t, ok) + rl2, ok := u.UnstructuredContent()["data"].(map[string]interface{})["release"] + assert.True(t, ok) + assert.True(t, proto.Equal(rl.(proto.Message), rl2.(proto.Message))) + } else { + assert.Equal(t, tt.resource, tt.want) + } + } else { + assert.Equal(t, tt.resource, tt.want) + } + }) + } +} + +func newSecret(owner string, data map[string]interface{}) *types.RawResource { + secret := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "data": data, + }} + if owner == "helm" { + secret.SetLabels(map[string]string{"owner": owner}) + } + if owner == "TILLER" { + secret.SetLabels(map[string]string{"OWNER": owner}) + } + return &types.RawResource{ + Type: "secret", + APIObject: types.APIObject{Object: secret}, + } +} + +func newConfigMap(owner string, data map[string]interface{}) *types.RawResource { + cfgMap := &unstructured.Unstructured{Object: map[string]interface{}{ + "apiVersion": "v1", + "kind": "configmap", + "data": data, + }} + if owner == "helm" { + cfgMap.SetLabels(map[string]string{"owner": owner}) + } + if owner == "TILLER" { + cfgMap.SetLabels(map[string]string{"OWNER": owner}) + } + return &types.RawResource{ + Type: "configmap", + APIObject: types.APIObject{Object: cfgMap}, + } +} + +func newV2Release() string { + a := rv2 + b, err := proto.Marshal(&a) + if err != nil { + logrus.Errorf("Failed to marshal release: %v", err) + } + buf := bytes.Buffer{} + gz := gzip.NewWriter(&buf) + gz.Write(b) + gz.Close() + return base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +func newV2ReleaseWithoutGzip() string { + a := rv2 + b, err := proto.Marshal(&a) + if err != nil { + logrus.Errorf("Failed to marshal release: %v", err) + } + return base64.StdEncoding.EncodeToString(b) +} + +func newV3Release() string { + b, err := json.Marshal(r) + if err != nil { + logrus.Errorf("Failed to marshal release: %v", err) + } + buf := bytes.Buffer{} + gz := gzip.NewWriter(&buf) + gz.Write(b) + gz.Close() + return base64.StdEncoding.EncodeToString(buf.Bytes()) +} + +func newV3ReleaseWithoutGzip() string { + b, err := json.Marshal(r) + if err != nil { + logrus.Errorf("Failed to marshal release: %v", err) + } + return base64.StdEncoding.EncodeToString(b) +} + +func newRequest(value string) *types.APIRequest { + req := &types.APIRequest{Query: url.Values{}} + if value != "" { + req.Query.Add("includeHelmData", value) + } + return req +} diff --git a/pkg/resources/schema.go b/pkg/resources/schema.go index 4bb18b8f..931b89b1 100644 --- a/pkg/resources/schema.go +++ b/pkg/resources/schema.go @@ -54,11 +54,11 @@ func DefaultSchemaTemplates(cf *client.Factory, apigroups.Template(discovery), { ID: "configmap", - Formatter: formatters.DropHelmData, + Formatter: formatters.HandleHelmData, }, { ID: "secret", - Formatter: formatters.DropHelmData, + Formatter: formatters.HandleHelmData, }, { ID: "pod",