diff --git a/go.mod b/go.mod index 8f421fca21..d43825414a 100644 --- a/go.mod +++ b/go.mod @@ -47,7 +47,7 @@ require ( github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 github.com/pkg/errors v0.9.1 github.com/pmezard/go-difflib v1.0.0 - github.com/replicatedhq/kotskinds v0.0.0-20230724164735-f83482cc9cfe + github.com/replicatedhq/kotskinds v0.0.0-20230925145827-c8f611d61fc1 github.com/replicatedhq/kurlkinds v1.3.6 github.com/replicatedhq/troubleshoot v0.72.1 github.com/replicatedhq/yaml/v3 v3.0.0-beta5-replicatedhq diff --git a/go.sum b/go.sum index 85f0a2a2de..d27d552f61 100644 --- a/go.sum +++ b/go.sum @@ -1527,6 +1527,10 @@ github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqn github.com/remyoudompheng/bigfft v0.0.0-20170806203942-52369c62f446/go.mod h1:uYEyJGbgTkfkS4+E/PavXkNJcbFIpEtjt2B0KDQ5+9M= github.com/replicatedhq/kotskinds v0.0.0-20230724164735-f83482cc9cfe h1:3AJInd06UxzqHmgy8+24CPsT2tYSE0zToJZyuX9q+MA= github.com/replicatedhq/kotskinds v0.0.0-20230724164735-f83482cc9cfe/go.mod h1:QjhIUu3+OmHZ09u09j3FCoTt8F3BYtQglS+OLmftu9I= +github.com/replicatedhq/kotskinds v0.0.0-20230921181252-176885c4c65d h1:P1PY8o/pvb9B+cCFepYUGhqzoAFYe9H0pz0SKq2GJR0= +github.com/replicatedhq/kotskinds v0.0.0-20230921181252-176885c4c65d/go.mod h1:QjhIUu3+OmHZ09u09j3FCoTt8F3BYtQglS+OLmftu9I= +github.com/replicatedhq/kotskinds v0.0.0-20230925145827-c8f611d61fc1 h1:DqyT2B0ud9S1p78oTGV4mjtVvo3GqjyLbc17SS77kec= +github.com/replicatedhq/kotskinds v0.0.0-20230925145827-c8f611d61fc1/go.mod h1:QjhIUu3+OmHZ09u09j3FCoTt8F3BYtQglS+OLmftu9I= github.com/replicatedhq/kurlkinds v1.3.6 h1:/dhS32cSSZR4yS4vA8EquBvz+VgJCyTqBO9Xw+6eI4M= github.com/replicatedhq/kurlkinds v1.3.6/go.mod h1:c5+hoAkuARgftB2Ft3RCyWRZZPhL0clHEaw7XoGDAg4= github.com/replicatedhq/termui/v3 v3.1.1-0.20200811145416-f40076d26851 h1:eRlNDHxGfVkPCRXbA4BfQJvt5DHjFiTtWy3R/t4djyY= diff --git a/pkg/k8sutil/kotsadm.go b/pkg/k8sutil/kotsadm.go index bc101e168b..1879d1a2cc 100644 --- a/pkg/k8sutil/kotsadm.go +++ b/pkg/k8sutil/kotsadm.go @@ -9,6 +9,7 @@ import ( types "github.com/replicatedhq/kots/pkg/k8sutil/types" kotsadmtypes "github.com/replicatedhq/kots/pkg/kotsadm/types" "github.com/replicatedhq/kots/pkg/util" + "github.com/segmentio/ksuid" corev1 "k8s.io/api/core/v1" kuberneteserrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -81,11 +82,23 @@ func IsKotsadmClusterScoped(ctx context.Context, clientset kubernetes.Interface, return false } -func GetKotsadmIDConfigMap() (*corev1.ConfigMap, error) { - clientset, err := GetClientset() - if err != nil { - return nil, errors.Wrap(err, "failed to get clientset") +func GetKotsadmClusterID(clientset kubernetes.Interface) string { + var clusterID string + configMap, err := GetKotsadmIDConfigMap(clientset) + // if configmap is not found, generate a new guid and create a new configmap, if configmap is found, use the existing guid, otherwise generate + if err != nil && !kuberneteserrors.IsNotFound(err) { + clusterID = ksuid.New().String() + } else if configMap != nil { + clusterID = configMap.Data["id"] + } else { + // configmap is missing for some reason, recreate with new guid, this will appear as a new instance in the report + clusterID = ksuid.New().String() + CreateKotsadmIDConfigMap(clientset, clusterID) } + return clusterID +} + +func GetKotsadmIDConfigMap(clientset kubernetes.Interface) (*corev1.ConfigMap, error) { namespace := util.PodNamespace existingConfigmap, err := clientset.CoreV1().ConfigMaps(namespace).Get(context.TODO(), KotsadmIDConfigMapName, metav1.GetOptions{}) if err != nil && !kuberneteserrors.IsNotFound(err) { @@ -96,12 +109,8 @@ func GetKotsadmIDConfigMap() (*corev1.ConfigMap, error) { return existingConfigmap, nil } -func CreateKotsadmIDConfigMap(kotsadmID string) error { +func CreateKotsadmIDConfigMap(clientset kubernetes.Interface, kotsadmID string) error { var err error = nil - clientset, err := GetClientset() - if err != nil { - return err - } configmap := corev1.ConfigMap{ TypeMeta: metav1.TypeMeta{ APIVersion: "v1", @@ -136,11 +145,7 @@ func IsKotsadmIDConfigMapPresent() (bool, error) { return true, nil } -func UpdateKotsadmIDConfigMap(kotsadmID string) error { - clientset, err := GetClientset() - if err != nil { - return errors.Wrap(err, "failed to get clientset") - } +func UpdateKotsadmIDConfigMap(clientset kubernetes.Interface, kotsadmID string) error { namespace := util.PodNamespace existingConfigMap, err := clientset.CoreV1().ConfigMaps(namespace).Get(context.TODO(), KotsadmIDConfigMapName, metav1.GetOptions{}) if err != nil && !kuberneteserrors.IsNotFound(err) { diff --git a/pkg/k8sutil/kotsadm_test.go b/pkg/k8sutil/kotsadm_test.go new file mode 100644 index 0000000000..a0ee3f1ae6 --- /dev/null +++ b/pkg/k8sutil/kotsadm_test.go @@ -0,0 +1,62 @@ +package k8sutil + +import ( + "context" + "testing" + + "gopkg.in/go-playground/assert.v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" +) + +func TestGetKotsadmClusterID(t *testing.T) { + + type args struct { + clientset kubernetes.Interface + } + tests := []struct { + name string + args args + want string + shouldCreateConfigMap bool + }{ + { + name: "configmap exists", + args: args{ + clientset: fake.NewSimpleClientset(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: KotsadmIDConfigMapName}, + Data: map[string]string{"id": "cluster-id"}, + }), + }, + want: "cluster-id", + shouldCreateConfigMap: false, + }, + { + name: "configmap does not exist, should create", + args: args{ + clientset: fake.NewSimpleClientset(), + }, + want: "", + shouldCreateConfigMap: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetKotsadmClusterID(tt.args.clientset) + if tt.want != "" { + assert.Equal(t, tt.want, got) + } else { + // a random uuid is generated + assert.NotEqual(t, "", got) + } + + if tt.shouldCreateConfigMap { + // should have created the configmap if it didn't exist + _, err := tt.args.clientset.CoreV1().ConfigMaps("").Get(context.TODO(), KotsadmIDConfigMapName, metav1.GetOptions{}) + assert.Equal(t, nil, err) + } + }) + } +} diff --git a/pkg/kotsutil/yaml.go b/pkg/kotsutil/yaml.go index fb31c73d5f..d613deedd0 100644 --- a/pkg/kotsutil/yaml.go +++ b/pkg/kotsutil/yaml.go @@ -2,10 +2,13 @@ package kotsutil import ( "bytes" + "fmt" + "strings" "github.com/pkg/errors" "github.com/replicatedhq/kots/pkg/util" yaml "github.com/replicatedhq/yaml/v3" + goyaml "gopkg.in/yaml.v3" k8syaml "sigs.k8s.io/yaml" ) @@ -85,3 +88,159 @@ func removeNilFieldsFromMap(input map[string]interface{}) bool { return removedItems } + +func MergeYAMLNodes(targetNodes []*goyaml.Node, overrideNodes []*goyaml.Node) []*goyaml.Node { + // Since inputs are arrays and not maps, we need to: + // 1. Copy all keys in targetNodes, overriding the ones that match from overrideNodes + // 2. Add all keys from overrideNodes that don't exist in targetNodes + + if len(overrideNodes) == 0 { + return targetNodes + } + + if len(targetNodes) == 0 { + return overrideNodes + } + + // Special case where top level node is either a mapping node or an array + if len(targetNodes) == 1 && len(overrideNodes) == 1 { + if targetNodes[0].Kind == goyaml.MappingNode && overrideNodes[0].Kind == goyaml.MappingNode { + return []*goyaml.Node{ + { + Kind: goyaml.MappingNode, + Content: MergeYAMLNodes(targetNodes[0].Content, overrideNodes[0].Content), + }, + } + } + + if targetNodes[0].Value == overrideNodes[0].Value { + return overrideNodes + } + + return append(targetNodes, overrideNodes...) + } + + // 1. Copy all keys in targetNodes, overriding the ones that match from overrideNodes + newNodes := make([]*goyaml.Node, 0) + for i := 0; i < len(targetNodes)-1; i += 2 { + var additionalNode *goyaml.Node + for j := 0; j < len(overrideNodes)-1; j += 2 { + nodeNameI := targetNodes[i] + nodeValueI := targetNodes[i+1] + + nodeNameJ := overrideNodes[j] + nodeValueJ := overrideNodes[j+1] + + if nodeNameI.Value != nodeNameJ.Value { + continue + } + + additionalNode = &goyaml.Node{ + Kind: nodeValueJ.Kind, + Tag: nodeValueJ.Tag, + Line: nodeValueJ.Line, + Style: nodeValueJ.Style, + Anchor: nodeValueJ.Anchor, + Value: nodeValueJ.Value, + Alias: nodeValueJ.Alias, + HeadComment: nodeValueJ.HeadComment, + LineComment: nodeValueJ.LineComment, + FootComment: nodeValueJ.FootComment, + Column: nodeValueJ.Column, + } + + if nodeValueI.Kind == goyaml.MappingNode && nodeValueJ.Kind == goyaml.MappingNode { + additionalNode.Content = MergeYAMLNodes(nodeValueI.Content, nodeValueJ.Content) + } else { + additionalNode.Content = nodeValueJ.Content + } + + break + } + + if additionalNode != nil { + newNodes = append(newNodes, targetNodes[i], additionalNode) + } else { + newNodes = append(newNodes, targetNodes[i], targetNodes[i+1]) + } + } + + // 2. Add all keys from overrideNodes that don't exist in targetNodes + for j := 0; j < len(overrideNodes)-1; j += 2 { + isFound := false + for i := 0; i < len(newNodes)-1; i += 2 { + nodeNameI := newNodes[i] + nodeValueI := newNodes[i+1] + + additionalNodeName := overrideNodes[j] + additionalNodeValue := overrideNodes[j+1] + + if nodeNameI.Value != additionalNodeName.Value { + continue + } + + if nodeValueI.Kind == goyaml.MappingNode && additionalNodeValue.Kind == goyaml.MappingNode { + nodeValueI.Content = MergeYAMLNodes(nodeValueI.Content, additionalNodeValue.Content) + } + + isFound = true + break + } + + if !isFound { + newNodes = append(newNodes, overrideNodes[j], overrideNodes[j+1]) + } + } + + return newNodes +} + +func ContentToDocNode(doc *goyaml.Node, nodes []*goyaml.Node) *goyaml.Node { + if doc == nil { + return &goyaml.Node{ + Kind: goyaml.DocumentNode, + Content: nodes, + } + } + return &goyaml.Node{ + Kind: doc.Kind, + Tag: doc.Tag, + Line: doc.Line, + Style: doc.Style, + Anchor: doc.Anchor, + Value: doc.Value, + Alias: doc.Alias, + HeadComment: doc.HeadComment, + LineComment: doc.LineComment, + FootComment: doc.FootComment, + Column: doc.Column, + Content: nodes, + } +} + +func NodeToYAML(node *goyaml.Node) ([]byte, error) { + var renderedContents bytes.Buffer + yamlEncoder := goyaml.NewEncoder(&renderedContents) + yamlEncoder.SetIndent(2) // this may change indentations of the original values.yaml, but this matches out tests + err := yamlEncoder.Encode(node) + if err != nil { + return nil, errors.Wrap(err, "marshal") + } + + return renderedContents.Bytes(), nil +} + +// Handy functions for printing YAML nodes +func PrintNodes(nodes []*goyaml.Node, i int) { + for _, n := range nodes { + PrintNode(n, i) + } +} +func PrintNode(n *goyaml.Node, i int) { + if n == nil { + return + } + indent := strings.Repeat(" ", i*2) + fmt.Printf("%stag:%v, style:%v, kind:%v, value:%v\n", indent, n.Tag, n.Style, n.Kind, n.Value) + PrintNodes(n.Content, i+1) +} diff --git a/pkg/reporting/app.go b/pkg/reporting/app.go index a7942bbdb7..38a287c5c4 100644 --- a/pkg/reporting/app.go +++ b/pkg/reporting/app.go @@ -138,13 +138,18 @@ func initFromDownstream() error { return errors.Wrap(err, "failed to check configmap") } + clientset, err := k8sutil.GetClientset() + if err != nil { + return errors.Wrap(err, "failed to get clientset") + } + if isKotsadmIDGenerated && !cmpExists { kotsadmID := ksuid.New().String() - err = k8sutil.CreateKotsadmIDConfigMap(kotsadmID) + err = k8sutil.CreateKotsadmIDConfigMap(clientset, kotsadmID) } else if !isKotsadmIDGenerated && !cmpExists { - err = k8sutil.CreateKotsadmIDConfigMap(clusterID) + err = k8sutil.CreateKotsadmIDConfigMap(clientset, clusterID) } else if !isKotsadmIDGenerated && cmpExists { - err = k8sutil.UpdateKotsadmIDConfigMap(clusterID) + err = k8sutil.UpdateKotsadmIDConfigMap(clientset, clusterID) } else { // id exists and so as configmap, noop } @@ -181,16 +186,7 @@ func GetReportingInfo(appID string) *types.ReportingInfo { if util.IsHelmManaged() { r.ClusterID = clusterID } else { - configMap, err := k8sutil.GetKotsadmIDConfigMap() - if err != nil { - r.ClusterID = ksuid.New().String() - } else if configMap != nil { - r.ClusterID = configMap.Data["id"] - } else { - // configmap is missing for some reason, recreate with new guid, this will appear as a new instance in the report - r.ClusterID = ksuid.New().String() - k8sutil.CreateKotsadmIDConfigMap(r.ClusterID) - } + r.ClusterID = k8sutil.GetKotsadmClusterID(clientset) di, err := getDownstreamInfo(appID) if err != nil { diff --git a/pkg/rewrite/rewrite.go b/pkg/rewrite/rewrite.go index cca1f3e5a5..d7cc109893 100644 --- a/pkg/rewrite/rewrite.go +++ b/pkg/rewrite/rewrite.go @@ -74,6 +74,7 @@ func Rewrite(rewriteOptions RewriteOptions) error { CurrentVersionIsRequired: rewriteOptions.Installation.Spec.IsRequired, CurrentReplicatedRegistryDomain: rewriteOptions.Installation.Spec.ReplicatedRegistryDomain, CurrentReplicatedProxyDomain: rewriteOptions.Installation.Spec.ReplicatedProxyDomain, + CurrentReplicatedChartNames: rewriteOptions.Installation.Spec.ReplicatedChartNames, EncryptionKey: rewriteOptions.Installation.Spec.EncryptionKey, License: rewriteOptions.License, AppSequence: rewriteOptions.AppSequence, diff --git a/pkg/upstream/fetch.go b/pkg/upstream/fetch.go index 3f325d4fe0..09d415fb47 100644 --- a/pkg/upstream/fetch.go +++ b/pkg/upstream/fetch.go @@ -49,6 +49,7 @@ func downloadUpstream(upstreamURI string, fetchOptions *types.FetchOptions) (*ty pickVersionIsRequired(fetchOptions), pickReplicatedRegistryDomain(fetchOptions), pickReplicatedProxyDomain(fetchOptions), + pickReplicatedChartNames(fetchOptions), fetchOptions.AppSlug, fetchOptions.AppSequence, fetchOptions.Airgap != nil, @@ -110,3 +111,11 @@ func pickCursor(fetchOptions *types.FetchOptions) replicatedapp.ReplicatedCursor Cursor: fetchOptions.CurrentCursor, } } + +func pickReplicatedChartNames(fetchOptions *types.FetchOptions) []string { + // TODO: airgap + // if fetchOptions.Airgap != nil { + // return fetchOptions.Airgap.Spec.ReplicatedChartNames + // } + return fetchOptions.CurrentReplicatedChartNames +} diff --git a/pkg/upstream/replicated.go b/pkg/upstream/replicated.go index 78529b0349..2e4b09a65b 100644 --- a/pkg/upstream/replicated.go +++ b/pkg/upstream/replicated.go @@ -55,6 +55,7 @@ type Release struct { ReleasedAt *time.Time ReplicatedRegistryDomain string ReplicatedProxyDomain string + ReplicatedChartNames []string Manifests map[string][]byte } @@ -125,6 +126,7 @@ func downloadReplicated( isRequired bool, replicatedRegistryDomain string, replicatedProxyDomain string, + replicatedChartNames []string, appSlug string, appSequence int64, isAirgap bool, @@ -136,7 +138,7 @@ func downloadReplicated( var release *Release if localPath != "" { - parsedLocalRelease, err := readReplicatedAppFromLocalPath(localPath, updateCursor, versionLabel, isRequired, replicatedRegistryDomain, replicatedProxyDomain) + parsedLocalRelease, err := readReplicatedAppFromLocalPath(localPath, updateCursor, versionLabel, isRequired, replicatedRegistryDomain, replicatedProxyDomain, replicatedChartNames) if err != nil { return nil, errors.Wrap(err, "failed to read replicated app from local path") } @@ -287,6 +289,7 @@ func downloadReplicated( Files: files, Type: "replicated", UpdateCursor: release.UpdateCursor.Cursor, + License: license, ChannelID: channelID, ChannelName: channelName, VersionLabel: release.VersionLabel, @@ -295,12 +298,13 @@ func downloadReplicated( ReleasedAt: release.ReleasedAt, ReplicatedRegistryDomain: release.ReplicatedRegistryDomain, ReplicatedProxyDomain: release.ReplicatedProxyDomain, + ReplicatedChartNames: release.ReplicatedChartNames, } return upstream, nil } -func readReplicatedAppFromLocalPath(localPath string, localCursor replicatedapp.ReplicatedCursor, versionLabel string, isRequired bool, replicatedRegistryDomain string, replicatedProxyDomain string) (*Release, error) { +func readReplicatedAppFromLocalPath(localPath string, localCursor replicatedapp.ReplicatedCursor, versionLabel string, isRequired bool, replicatedRegistryDomain string, replicatedProxyDomain string, replicatedChartNames []string) (*Release, error) { release := Release{ Manifests: make(map[string][]byte), UpdateCursor: localCursor, @@ -308,6 +312,7 @@ func readReplicatedAppFromLocalPath(localPath string, localCursor replicatedapp. IsRequired: isRequired, ReplicatedRegistryDomain: replicatedRegistryDomain, ReplicatedProxyDomain: replicatedProxyDomain, + ReplicatedChartNames: replicatedChartNames, } err := filepath.Walk(localPath, @@ -370,6 +375,7 @@ func downloadReplicatedApp(replicatedUpstream *replicatedapp.ReplicatedUpstream, releasedAtStr := getResp.Header.Get("X-Replicated-ReleasedAt") replicatedRegistryDomain := getResp.Header.Get("X-Replicated-ReplicatedRegistryDomain") replicatedProxyDomain := getResp.Header.Get("X-Replicated-ReplicatedProxyDomain") + replicatedChartNamesStr := getResp.Header.Get("X-Replicated-ReplicatedChartNames") var releasedAt *time.Time r, err := time.Parse(time.RFC3339, releasedAtStr) @@ -379,6 +385,11 @@ func downloadReplicatedApp(replicatedUpstream *replicatedapp.ReplicatedUpstream, isRequired, _ := strconv.ParseBool(isRequiredStr) + var replicatedChartNames []string + if replicatedChartNamesStr != "" { + replicatedChartNames = strings.Split(replicatedChartNamesStr, ",") + } + gzf, err := gzip.NewReader(getResp.Body) if err != nil { return nil, errors.Wrap(err, "failed to create new gzip reader") @@ -396,6 +407,7 @@ func downloadReplicatedApp(replicatedUpstream *replicatedapp.ReplicatedUpstream, ReleasedAt: releasedAt, ReplicatedRegistryDomain: replicatedRegistryDomain, ReplicatedProxyDomain: replicatedProxyDomain, + ReplicatedChartNames: replicatedChartNames, // NOTE: release notes come from Application spec } tarReader := tar.NewReader(gzf) diff --git a/pkg/upstream/types/types.go b/pkg/upstream/types/types.go index ae9df8ee7a..ef04ac7097 100644 --- a/pkg/upstream/types/types.go +++ b/pkg/upstream/types/types.go @@ -27,6 +27,7 @@ type Upstream struct { Type string Files []UpstreamFile UpdateCursor string + License *kotsv1beta1.License ChannelID string ChannelName string VersionLabel string @@ -35,6 +36,7 @@ type Upstream struct { ReleasedAt *time.Time ReplicatedRegistryDomain string ReplicatedProxyDomain string + ReplicatedChartNames []string EncryptionKey string } @@ -94,10 +96,12 @@ type FetchOptions struct { CurrentCursor string CurrentChannelID string CurrentChannelName string + CurrentReleaseSequence int64 CurrentVersionLabel string CurrentVersionIsRequired bool CurrentReplicatedRegistryDomain string CurrentReplicatedProxyDomain string + CurrentReplicatedChartNames []string ChannelChanged bool AppSlug string AppSequence int64 diff --git a/pkg/upstream/write.go b/pkg/upstream/write.go index 0d569f8cef..32f850a195 100644 --- a/pkg/upstream/write.go +++ b/pkg/upstream/write.go @@ -1,19 +1,34 @@ package upstream import ( + "archive/tar" "bytes" + "compress/gzip" "encoding/base64" + "encoding/json" + "fmt" + "io" + "io/fs" "io/ioutil" "os" "path" + "path/filepath" + "strings" "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/archives" + "github.com/replicatedhq/kots/pkg/buildversion" "github.com/replicatedhq/kots/pkg/crypto" + "github.com/replicatedhq/kots/pkg/k8sutil" + "github.com/replicatedhq/kots/pkg/kotsadm" "github.com/replicatedhq/kots/pkg/kotsutil" + "github.com/replicatedhq/kots/pkg/store" "github.com/replicatedhq/kots/pkg/upstream/types" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "gopkg.in/yaml.v3" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" serializer "k8s.io/apimachinery/pkg/runtime/serializer/json" + "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" ) @@ -100,7 +115,25 @@ func WriteUpstream(u *types.Upstream, options types.WriteOptions) error { u.Files[i] = file } - if err := ioutil.WriteFile(fileRenderPath, file.Content, 0644); err != nil { + if archives.IsTGZ(file.Content) { + // this is a helm chart, so we need to check if it is or contains the replicated-sdk + reader := bytes.NewReader(file.Content) + replicatedSDKChartName, isReplicatedSDK, err := FindReplicatedSDKChart(reader, u.ReplicatedChartNames) + if err != nil { + return errors.Wrap(err, "failed to find replicated-sdk subchart") + } + + if replicatedSDKChartName != "" { + updatedContent, err := configureReplicatedSDK(file.Content, u, replicatedSDKChartName, isReplicatedSDK) + if err != nil { + return errors.Wrap(err, "failed to configure replicated sdk") + } + file.Content = updatedContent + u.Files[i] = file + } + } + + if err := os.WriteFile(fileRenderPath, file.Content, 0644); err != nil { return errors.Wrap(err, "failed to write upstream file") } } @@ -131,6 +164,7 @@ func WriteUpstream(u *types.Upstream, options types.WriteOptions) error { ReleaseNotes: u.ReleaseNotes, ReplicatedRegistryDomain: u.ReplicatedRegistryDomain, ReplicatedProxyDomain: u.ReplicatedProxyDomain, + ReplicatedChartNames: u.ReplicatedChartNames, EncryptionKey: encryptionKey, }, } @@ -198,3 +232,557 @@ func maybeEncryptIdentityConfig(identityConfig *kotsv1beta1.IdentityConfig) ([]b return b.Bytes(), nil } + +// FindReplicatedSDKChart will look for a chart with the name "replicated" or "replicated-sdk" in the archive +// and return the name of the chart and whether it is the parent chart or a subchart +func FindReplicatedSDKChart(archive io.Reader, replicatedChartNames []string) (string, bool, error) { + replicatedChartNamesMap := map[string]bool{} + for _, chartName := range replicatedChartNames { + replicatedChartNamesMap[chartName] = true + } + + gzReader, err := gzip.NewReader(archive) + if err != nil { + return "", false, errors.Wrap(err, "failed to create gzip reader") + } + defer gzReader.Close() + + tarReader := tar.NewReader(gzReader) + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return "", false, errors.Wrap(err, "failed to read header from tar") + } + + switch header.Typeflag { + case tar.TypeReg: + if filepath.Base(header.Name) == "Chart.yaml" { + content, err := io.ReadAll(tarReader) + if err != nil { + return "", false, errors.Wrapf(err, "failed to read file %s", header.Name) + } + + chartInfo := struct { + ChartName string `json:"name" yaml:"name"` + }{} + if err := yaml.Unmarshal(content, &chartInfo); err != nil { + return "", false, errors.Wrapf(err, "failed to unmarshal %s", header.Name) + } + + if replicatedChartNamesMap[chartInfo.ChartName] { + // check if the sdk is the parent chart or a subchart based on the path + replicatedSDKChartName := chartInfo.ChartName + isReplicatedSDK := !strings.Contains(filepath.Dir(header.Name), string(os.PathSeparator)) + return replicatedSDKChartName, isReplicatedSDK, nil + } + } + } + } + + return "", false, nil +} + +func configureReplicatedSDK(chartContent []byte, u *types.Upstream, replicatedSDKChartName string, isReplicatedSDK bool) ([]byte, error) { + reader := bytes.NewReader(chartContent) + unrenderedContents, pathInArchive, extractedArchiveRoot, err := findTopLevelChartValues(reader) + if err != nil { + return nil, errors.Wrap(err, "failed to find top level chart values") + } + defer os.RemoveAll(extractedArchiveRoot) + + clientset, err := k8sutil.GetClientset() + if err != nil { + return nil, errors.Wrap(err, "failed to get clientset") + } + + renderedValuesContents, err := renderValuesYAMLForLicense(clientset, store.GetStore(), unrenderedContents, u, replicatedSDKChartName, isReplicatedSDK) + if err != nil { + return nil, errors.Wrap(err, "render values.yaml") + } + + if err := os.WriteFile(filepath.Join(extractedArchiveRoot, pathInArchive), renderedValuesContents, 0644); err != nil { + return nil, errors.Wrap(err, "write rendered values.yaml") + } + + updatedArchive, err := packageChartArchive(extractedArchiveRoot) + if err != nil { + return nil, errors.Wrap(err, "package chart archive") + } + defer os.RemoveAll(updatedArchive) + + renderedContents, err := os.ReadFile(updatedArchive) + if err != nil { + return nil, errors.Wrap(err, "read updated archive") + } + + return renderedContents, nil +} + +func findTopLevelChartValues(r io.Reader) (valuesYaml []byte, pathInArchive string, workspace string, finalErr error) { + workspace, err := os.MkdirTemp("", "extracted-chart-") + if err != nil { + finalErr = errors.Wrap(err, "failed to create temp directory") + return + } + + defer func() { + if finalErr != nil { + os.RemoveAll(workspace) + workspace = "" + } + }() + + gzReader, err := gzip.NewReader(r) + if err != nil { + finalErr = errors.Wrap(err, "failed to create gzip reader") + return + } + + tarReader := tar.NewReader(gzReader) + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + finalErr = errors.Wrap(err, "failed to read header from tar") + return + } + + switch header.Typeflag { + case tar.TypeDir: + if err := os.Mkdir(filepath.Join(workspace, header.Name), fs.FileMode(header.Mode)); err != nil { + finalErr = errors.Wrap(err, "failed to create directory from archive") + return + } + case tar.TypeReg: + content, err := io.ReadAll(tarReader) + if err != nil { + finalErr = errors.Wrap(err, "failed to read file") + return + } + + if filepath.Base(header.Name) == "values.yaml" { + // only get the values.yaml from the top level chart + p := filepath.Dir(header.Name) + if !strings.Contains(p, string(os.PathSeparator)) { + pathInArchive = header.Name + valuesYaml = content + } + } + + dir := filepath.Dir(filepath.Join(workspace, header.Name)) + if err := os.MkdirAll(dir, 0700); err != nil { + finalErr = errors.Wrap(err, "failed to create directory from filename") + return + } + + outFile, err := os.Create(filepath.Join(workspace, header.Name)) + if err != nil { + finalErr = errors.Wrap(err, "failed to create file") + return + } + defer outFile.Close() + if err := os.WriteFile(outFile.Name(), content, header.FileInfo().Mode()); err != nil { + finalErr = errors.Wrap(err, "failed to write file") + return + } + } + } + + return +} + +type LicenseField struct { + Name string `json:"name" yaml:"name"` + Title string `json:"title" yaml:"title"` + Description string `json:"description" yaml:"description"` + Value interface{} `json:"value" yaml:"value"` + ValueType string `json:"valueType" yaml:"valueType"` + HelmPath *string `json:"helmPath,omitempty" yaml:"helmPath,omitempty"` + IsHidden bool `json:"isHidden,omitempty" yaml:"isHidden,omitempty"` + Signature LicenseFieldSignature `json:"signature,omitempty" yaml:"signature,omitempty"` +} + +type LicenseFieldSignature struct { + V1 string `json:"v1,omitempty" yaml:"v1,omitempty"` // this is a base64 encoded string because yaml.Unmarshal doesn't automatically convert base64 to []byte like json.Unmarshal does +} + +func renderValuesYAMLForLicense(clientset kubernetes.Interface, kotsStore store.Store, unrenderedContents []byte, u *types.Upstream, replicatedSDKChartName string, isReplicatedSDK bool) ([]byte, error) { + var licenseBytes []byte + var licenseFields map[string]LicenseField + var clusterID string + var appID string + var dockerCfgJson string + if u.License != nil { + licenseBytes = MustMarshalLicense(u.License) + licenseFields = map[string]LicenseField{} + for k, v := range u.License.Spec.Entitlements { + // TODO: support individual license field signatures + licenseFields[k] = LicenseField{ + Name: k, + Title: v.Title, + Description: v.Description, + Value: v.Value.Value(), + ValueType: v.ValueType, + } + } + appSlug := u.License.Spec.AppSlug + + auth := fmt.Sprintf("%s:%s", u.License.Spec.LicenseID, u.License.Spec.LicenseID) + encodedAuth := base64.StdEncoding.EncodeToString([]byte(auth)) + dockercfg := map[string]interface{}{ + "auths": map[string]interface{}{ + u.ReplicatedProxyDomain: map[string]string{ + "auth": encodedAuth, + }, + u.ReplicatedRegistryDomain: map[string]string{ + "auth": encodedAuth, + }, + }, + } + + b, err := json.Marshal(dockercfg) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal dockercfg") + } + + dockerCfgJson = base64.StdEncoding.EncodeToString(b) + + clusterID = k8sutil.GetKotsadmClusterID(clientset) + appID, err = kotsStore.GetAppIDFromSlug(appSlug) + if err != nil { + return nil, errors.Wrap(err, "failed to get app id from slug") + } + } + + opts := ReplicatedValuesOptions{ + LicenseYAML: licenseBytes, + ClusterID: clusterID, + AppID: appID, + } + + var valuesNodes yaml.Node + if err := yaml.Unmarshal([]byte(unrenderedContents), &valuesNodes); err != nil { + return nil, errors.Wrap(err, "unmarshal values") + } + + if len(valuesNodes.Content) == 0 { + return nil, errors.New("no content") + } + + if isReplicatedSDK { + err := addReplicatedValuesForSDK(valuesNodes.Content[0], opts, "", true) + if err != nil { + return nil, errors.Wrap(err, "add values for the replicated sdk chart") + } + } else if replicatedSDKChartName != "" { + // replicated sdk is included as a subchart. + // make sure to add the values under the subchart name + // as helm expects the field name to match the subchart name + err := addReplicatedValuesForSDK(valuesNodes.Content[0], opts, replicatedSDKChartName, false) + if err != nil { + return nil, errors.Wrap(err, "add values for the replicated sdk subchart") + } + } + + globalOpts := ReplicatedGlobalValuesOptions{ + ChannelName: u.License.Spec.ChannelName, + CustomerEmail: u.License.Spec.CustomerEmail, + CustomerName: u.License.Spec.CustomerName, + DockerConfigJSON: dockerCfgJson, + LicenseID: u.License.Spec.LicenseID, + LicenseType: u.License.Spec.LicenseType, + LicenseFields: licenseFields, + } + + err := addReplicatedGlobalValues(valuesNodes.Content[0], globalOpts) + if err != nil { + return nil, errors.Wrap(err, "add replicated global values") + } + + licenseFieldNodes, err := convertLicenseFieldsToYamlNodes(licenseFields) + if err != nil { + return nil, errors.Wrap(err, "convert license fields to yaml nodes") + } + + newValues := kotsutil.ContentToDocNode(&valuesNodes, kotsutil.MergeYAMLNodes(valuesNodes.Content, licenseFieldNodes)) + + renderedContents, err := kotsutil.NodeToYAML(newValues) + if err != nil { + return nil, errors.Wrap(err, "render values") + } + + return renderedContents, nil +} + +type ReplicatedValuesOptions struct { + LicenseYAML []byte + ClusterID string + AppID string +} + +func addReplicatedValuesForSDK(doc *yaml.Node, opts ReplicatedValuesOptions, replicatedSDKSubchartName string, isReplicatedSDK bool) error { + replicatedValues := map[string]interface{}{ + "license": string(opts.LicenseYAML), + "replicatedID": opts.ClusterID, + "appID": opts.AppID, + "userAgent": buildversion.GetUserAgent(), + "airgap": kotsadm.IsAirgap(), + "replicatedAppEndpoint": "https://replicated-app-cbodonnell.okteto.repldev.com", // TODO: remove after testing + } + + targetNode := doc + hasReplicatedValues := false + + if replicatedSDKSubchartName != "" { + for i, n := range doc.Content { + if n.Value == replicatedSDKSubchartName { + targetNode = doc.Content[i+1] + hasReplicatedValues = true + break + } + } + } + + v := replicatedValues + if replicatedSDKSubchartName != "" && !hasReplicatedValues { + v = map[string]interface{}{ + replicatedSDKSubchartName: replicatedValues, + } + } + + additionalYAML, err := yaml.Marshal(v) + if err != nil { + return errors.Wrap(err, "marshal additional values") + } + + var additionalNode yaml.Node + if err := yaml.Unmarshal([]byte(additionalYAML), &additionalNode); err != nil { + return errors.Wrap(err, "unmarshal additional values") + } + + if hasReplicatedValues || isReplicatedSDK { + targetNode.Content = kotsutil.MergeYAMLNodes(targetNode.Content, additionalNode.Content[0].Content) + } else { + targetNode.Content = append(targetNode.Content, additionalNode.Content[0].Content...) + } + + return nil +} + +type ReplicatedGlobalValuesOptions struct { + ChannelName string + CustomerEmail string + CustomerName string + DockerConfigJSON string + LicenseID string + LicenseType string + LicenseFields map[string]LicenseField +} + +func addReplicatedGlobalValues(doc *yaml.Node, opts ReplicatedGlobalValuesOptions) error { + targetNode := doc + hasGlobal := false + for i, n := range doc.Content { + if n.Value == "global" { + targetNode = doc.Content[i+1] + hasGlobal = true + break + } + } + + hasGlobalReplicated := false + if hasGlobal { + for i, n := range targetNode.Content { + if n.Value == "replicated" { + targetNode = targetNode.Content[i+1] + hasGlobalReplicated = true + break + } + } + } + + replicatedValues := map[string]interface{}{ + "channelName": opts.ChannelName, + "customerName": opts.CustomerName, + "customerEmail": opts.CustomerEmail, + "licenseID": opts.LicenseID, + "licenseType": opts.LicenseType, + "dockerconfigjson": opts.DockerConfigJSON, + "licenseFields": opts.LicenseFields, + } + + v := replicatedValues + if !hasGlobalReplicated { + v = map[string]interface{}{ + "replicated": v, + } + if !hasGlobal { + v = map[string]interface{}{ + "global": v, + } + } + } + + additionalYAML, err := yaml.Marshal(v) + if err != nil { + return errors.Wrap(err, "marshal additional values") + } + + var additionalNode yaml.Node + if err := yaml.Unmarshal([]byte(additionalYAML), &additionalNode); err != nil { + return errors.Wrap(err, "unmarshal additional values") + } + + if hasGlobalReplicated || hasGlobal { + targetNode.Content = kotsutil.MergeYAMLNodes(targetNode.Content, additionalNode.Content[0].Content) + } else { + targetNode.Content = append(targetNode.Content, additionalNode.Content[0].Content...) + } + + return nil +} + +func convertLicenseFieldsToYamlNodes(licenseFields map[string]LicenseField) ([]*yaml.Node, error) { + nestedFields := map[string]interface{}{} + for fieldName, field := range licenseFields { + if field.HelmPath == nil || *field.HelmPath == "" { + // Not all license fields have a helm path + continue + } + + // Skip ".Values." prefix if it exists + pathParts := strings.Split(*field.HelmPath, ".") + if len(pathParts) < 2 { + return nil, errors.Errorf("field %s has invalid helm path %q", fieldName, *field.HelmPath) + } + if pathParts[0] == "" { + pathParts = pathParts[1:] + } + if pathParts[0] == "Values" { + pathParts = pathParts[1:] + } + + if len(pathParts) == 1 { + nestedFields[pathParts[0]] = field.Value + continue + } + + var nextMap map[string]interface{} + if m, ok := nestedFields[pathParts[0]]; ok { + nextMap = m.(map[string]interface{}) + } else { + nextMap = map[string]interface{}{} + } + + nestedFields[pathParts[0]] = nextMap + for i := 1; i < len(pathParts)-1; i++ { + var newNextMap map[string]interface{} + if m, ok := nextMap[pathParts[i]]; ok { + newNextMap = m.(map[string]interface{}) + } else { + newNextMap = map[string]interface{}{} + nextMap[pathParts[i]] = newNextMap + } + nextMap = newNextMap + } + nextMap[pathParts[len(pathParts)-1]] = field.Value + } + + valuesYaml, err := yaml.Marshal(nestedFields) + if err != nil { + return nil, errors.Wrap(err, "marshal values") + } + + var v yaml.Node + if err := yaml.Unmarshal([]byte(valuesYaml), &v); err != nil { + return nil, errors.Wrap(err, "unmarshal values") + } + + return v.Content, nil +} + +func packageChartArchive(extractedArchiveRoot string) (string, error) { + renderedPath, err := ioutil.TempFile("", "rendered-chart-") + if err != nil { + return "", errors.Wrap(err, "create temp file") + } + + file, err := os.Create(renderedPath.Name()) + if err != nil { + return "", errors.Wrap(err, "create file") + } + defer file.Close() + + gzipWriter := gzip.NewWriter(file) + defer gzipWriter.Close() + + tarWriter := tar.NewWriter(gzipWriter) + defer tarWriter.Close() + + err = filepath.Walk(extractedArchiveRoot, func(path string, info os.FileInfo, err error) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + fi, err := f.Stat() + if err != nil { + return err + } + + if fi.Mode().IsRegular() { + if err := addFileToTarWriter(extractedArchiveRoot, path, tarWriter); err != nil { + return err + } + } + + return nil + }) + if err != nil { + return "", errors.Wrap(err, "walk file tree") + } + + return renderedPath.Name(), nil +} + +func addFileToTarWriter(basePath string, filePath string, tarWriter *tar.Writer) error { + file, err := os.Open(filePath) + if err != nil { + return errors.New(fmt.Sprintf("Could not open file '%s', got error '%s'", filePath, err.Error())) + } + defer file.Close() + + stat, err := file.Stat() + if err != nil { + return errors.New(fmt.Sprintf("Could not get stat for file '%s', got error '%s'", filePath, err.Error())) + } + + rel, err := filepath.Rel(basePath, filePath) + if err != nil { + return errors.New(fmt.Sprintf("Could not get relative path for file '%s', got error '%s'", filePath, err.Error())) + } + + header := &tar.Header{ + Name: rel, + Size: stat.Size(), + Mode: int64(stat.Mode()), + ModTime: stat.ModTime(), + } + + err = tarWriter.WriteHeader(header) + if err != nil { + return errors.New(fmt.Sprintf("Could not write header for file '%s', got error '%s'", filePath, err.Error())) + } + + _, err = io.Copy(tarWriter, file) + if err != nil { + return errors.New(fmt.Sprintf("Could not copy the file '%s' data to the tarball, got error '%s'", filePath, err.Error())) + } + + return nil +} diff --git a/pkg/upstream/write_test.go b/pkg/upstream/write_test.go new file mode 100644 index 0000000000..3a0d987208 --- /dev/null +++ b/pkg/upstream/write_test.go @@ -0,0 +1,155 @@ +package upstream + +import ( + "fmt" + "reflect" + "testing" + + "github.com/golang/mock/gomock" + "github.com/pmezard/go-difflib/difflib" + "github.com/replicatedhq/kots/pkg/k8sutil" + "github.com/replicatedhq/kots/pkg/store" + mock_store "github.com/replicatedhq/kots/pkg/store/mock" + "github.com/replicatedhq/kots/pkg/upstream/types" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" +) + +func Test_renderValuesYAMLForLicense(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := mock_store.NewMockStore(ctrl) + + mockClientset := fake.NewSimpleClientset(&corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: k8sutil.KotsadmIDConfigMapName}, + Data: map[string]string{"id": "cluster-id"}, + }) + + type args struct { + clientset kubernetes.Interface + kotsStore store.Store + unrenderedContents []byte + u *types.Upstream + replicatedSDKChartName string + isReplicatedSDK bool + } + tests := []struct { + name string + args args + mockStoreExpectations func() + want []byte + wantErr bool + }{ + { + name: "sdk as a subchart - no existing replicated or global values", + args: args{ + clientset: mockClientset, + kotsStore: mockStore, + unrenderedContents: []byte(`# Comment in values +existing: value`), + u: &types.Upstream{ + License: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + LicenseID: "license-id", + AppSlug: "app-slug", + ChannelName: "channel-name", + Endpoint: "https://replicated.app", + Entitlements: map[string]kotsv1beta1.EntitlementField{ + "license-field": { + Title: "License Field", + Description: "This is a license field", + ValueType: "string", + Value: kotsv1beta1.EntitlementValue{ + Type: kotsv1beta1.String, + StrVal: "license-field-value", + }, + }, + }, + CustomerEmail: "customer@example.com", + CustomerName: "Customer Name", + LicenseType: "trial", + Signature: []byte{}, + }, + }, + ReplicatedRegistryDomain: "registry.replicated.com", + ReplicatedProxyDomain: "proxy.replicated.com", + }, + replicatedSDKChartName: "replicated", + isReplicatedSDK: false, + }, + mockStoreExpectations: func() { + mockStore.EXPECT().GetAppIDFromSlug("app-slug").Return("app-id", nil) + }, + want: []byte(`# Comment in values +existing: value +replicated: + airgap: false + appID: app-id + license: | + metadata: + creationTimestamp: null + spec: + appSlug: app-slug + channelName: channel-name + customerEmail: customer@example.com + customerName: Customer Name + endpoint: https://replicated.app + entitlements: + license-field: + description: This is a license field + title: License Field + value: license-field-value + valueType: string + licenseID: license-id + licenseType: trial + signature: "" + status: {} + replicatedID: cluster-id + userAgent: KOTS/v0.0.0-unknown +global: + replicated: + channelName: channel-name + customerEmail: customer@example.com + customerName: Customer Name + dockerconfigjson: eyJhdXRocyI6eyJwcm94eS5yZXBsaWNhdGVkLmNvbSI6eyJhdXRoIjoiYkdsalpXNXpaUzFwWkRwc2FXTmxibk5sTFdsayJ9LCJyZWdpc3RyeS5yZXBsaWNhdGVkLmNvbSI6eyJhdXRoIjoiYkdsalpXNXpaUzFwWkRwc2FXTmxibk5sTFdsayJ9fX0= + licenseFields: + license-field: + name: license-field + title: License Field + description: This is a license field + value: license-field-value + valueType: string + licenseID: license-id + licenseType: trial +`), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockStoreExpectations() + got, err := renderValuesYAMLForLicense(tt.args.clientset, tt.args.kotsStore, tt.args.unrenderedContents, tt.args.u, tt.args.replicatedSDKChartName, tt.args.isReplicatedSDK) + if (err != nil) != tt.wantErr { + t.Errorf("renderValuesYAMLForLicense() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("renderValuesYAMLForLicense() \n\n%s", fmtYAMLDiff(string(got), string(tt.want))) + } + }) + } +} + +func fmtYAMLDiff(got, want string) string { + diff := difflib.UnifiedDiff{ + A: difflib.SplitLines(got), + B: difflib.SplitLines(want), + FromFile: "Got", + ToFile: "Want", + Context: 1, + } + diffStr, _ := difflib.GetUnifiedDiffString(diff) + return fmt.Sprintf("got:\n%s \n\nwant:\n%s \n\ndiff:\n%s", got, want, diffStr) +}