diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index 5946b4430d..5bfa55c567 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -2824,7 +2824,7 @@ jobs: fail-fast: false matrix: cluster: [ - {distribution: kind, version: v1.27} + {distribution: kind, version: v1.27, instance-type: r1.medium} ] env: APP_SLUG: remove-app @@ -2842,6 +2842,7 @@ jobs: cluster-name: automated-kots-${{ github.run_id }}-${{ matrix.cluster.distribution }}-${{ matrix.cluster.version }} timeout-minutes: '120' ttl: 2h + instance-type: ${{ matrix.cluster.instance-type }} export-kubeconfig: true - name: download kots binary @@ -3261,6 +3262,33 @@ jobs: sleep 1 done + # validate that the conditional chart is installed + COUNTER=1 + while [ "$(helm ls -n "$APP_SLUG" | awk 'NR>1{print $1}' | grep my-conditional-chart-release)" == "" ]; do + ((COUNTER += 1)) + if [ $COUNTER -gt 120 ]; then + echo "Timed out waiting for my-conditional-chart-release to be installed" + helm ls -n "$APP_SLUG" + exit 1 + fi + sleep 1 + done + + # toggle the config option to exclude the conditional chart + ./bin/kots set config "$APP_SLUG" install_conditional_chart=0 --deploy --namespace "$APP_SLUG" + + # wait for my-conditional-chart-release to be uninstalled + COUNTER=1 + while [ "$(helm ls -n "$APP_SLUG" | awk 'NR>1{print $1}' | grep my-conditional-chart-release)" != "" ]; do + ((COUNTER += 1)) + if [ $COUNTER -gt 120 ]; then + echo "Timed out waiting for my-conditional-chart-release to be uninstalled" + helm ls -n "$APP_SLUG" + exit 1 + fi + sleep 1 + done + - name: Generate support bundle on failure if: failure() diff --git a/.image.env b/.image.env index 6460487150..1a80502535 100644 --- a/.image.env +++ b/.image.env @@ -1,8 +1,8 @@ # Generated file, do not modify. This file is generated from a text file containing a list of images. The # most recent tag is interpolated from the source repository and used to generate a fully qualified image # name. -MINIO_TAG='RELEASE.2023-08-23T10-07-06Z' -MC_TAG='RELEASE.2023-08-18T21-57-55Z' +MINIO_TAG='RELEASE.2023-09-23T03-47-50Z' +MC_TAG='RELEASE.2023-09-22T05-07-46Z' RQLITE_TAG='7.21.4' DEX_TAG='v2.37.0' SCHEMAHERO_TAG='0.14.0' diff --git a/Makefile b/Makefile index c14a2569ff..c4cb1e1106 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ include Makefile.build.mk CURRENT_USER := $(shell id -u -n) -MINIO_TAG ?= RELEASE.2023-08-23T10-07-06Z -MC_TAG ?= RELEASE.2023-08-18T21-57-55Z +MINIO_TAG ?= RELEASE.2023-09-23T03-47-50Z +MC_TAG ?= RELEASE.2023-09-22T05-07-46Z RQLITE_TAG ?= 7.21.4 DEX_TAG ?= v2.37.0 LVP_TAG ?= v0.5.4 diff --git a/deploy/Dockerfile b/deploy/Dockerfile index b048c9a90e..a1798e7841 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -136,9 +136,9 @@ RUN curl -fsSL -o kustomize.tar.gz "${KUSTOMIZE5_URL}" \ ENV KOTS_HELM_BIN_DIR=/usr/local/bin # Install helm v3 -ENV HELM3_VERSION=3.12.2 +ENV HELM3_VERSION=3.13.0 ENV HELM3_URL=https://get.helm.sh/helm-v${HELM3_VERSION}-linux-amd64.tar.gz -ENV HELM3_SHA256SUM=2b6efaa009891d3703869f4be80ab86faa33fa83d9d5ff2f6492a8aebe97b219 +ENV HELM3_SHA256SUM=138676351483e61d12dfade70da6c03d471bbdcac84eaadeb5e1d06fa114a24f RUN cd /tmp && curl -fsSL -o helm.tar.gz "${HELM3_URL}" \ && echo "${HELM3_SHA256SUM} helm.tar.gz" | sha256sum -c - \ && tar -xzvf helm.tar.gz \ diff --git a/deploy/okteto/okteto-v2.Dockerfile b/deploy/okteto/okteto-v2.Dockerfile index d07950faf7..d449cf5cf2 100644 --- a/deploy/okteto/okteto-v2.Dockerfile +++ b/deploy/okteto/okteto-v2.Dockerfile @@ -136,9 +136,9 @@ RUN curl -fsSL -o kustomize.tar.gz "${KUSTOMIZE5_URL}" \ ENV KOTS_HELM_BIN_DIR=/usr/local/bin # Install helm v3 -ENV HELM3_VERSION=3.12.2 +ENV HELM3_VERSION=3.13.0 ENV HELM3_URL=https://get.helm.sh/helm-v${HELM3_VERSION}-linux-amd64.tar.gz -ENV HELM3_SHA256SUM=2b6efaa009891d3703869f4be80ab86faa33fa83d9d5ff2f6492a8aebe97b219 +ENV HELM3_SHA256SUM=138676351483e61d12dfade70da6c03d471bbdcac84eaadeb5e1d06fa114a24f RUN cd /tmp && curl -fsSL -o helm.tar.gz "${HELM3_URL}" \ && echo "${HELM3_SHA256SUM} helm.tar.gz" | sha256sum -c - \ && tar -xzvf helm.tar.gz \ diff --git a/deploy/okteto/okteto.Dockerfile b/deploy/okteto/okteto.Dockerfile index f082c8ea7b..c98a583c61 100644 --- a/deploy/okteto/okteto.Dockerfile +++ b/deploy/okteto/okteto.Dockerfile @@ -137,9 +137,9 @@ RUN curl -fsSL -o kustomize.tar.gz "${KUSTOMIZE5_URL}" \ ENV KOTS_HELM_BIN_DIR=/usr/local/bin # Install helm v3 -ENV HELM3_VERSION=3.12.2 +ENV HELM3_VERSION=3.13.0 ENV HELM3_URL=https://get.helm.sh/helm-v${HELM3_VERSION}-linux-amd64.tar.gz -ENV HELM3_SHA256SUM=2b6efaa009891d3703869f4be80ab86faa33fa83d9d5ff2f6492a8aebe97b219 +ENV HELM3_SHA256SUM=138676351483e61d12dfade70da6c03d471bbdcac84eaadeb5e1d06fa114a24f RUN cd /tmp && curl -fsSL -o helm.tar.gz "${HELM3_URL}" \ && echo "${HELM3_SHA256SUM} helm.tar.gz" | sha256sum -c - \ && tar -xzvf helm.tar.gz \ diff --git a/go.mod b/go.mod index 8f421fca21..1c4a351de9 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-20231004174055-e6676d808a82 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..a620b1ab72 100644 --- a/go.sum +++ b/go.sum @@ -1525,8 +1525,8 @@ github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqn github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM= github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= 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-20231004174055-e6676d808a82 h1:QniKgIpcXu4wBMM4xIXGz+lkAU+hSIXFuVM+vxkNk0Y= +github.com/replicatedhq/kotskinds v0.0.0-20231004174055-e6676d808a82/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/hack/dev/skaffold.Dockerfile b/hack/dev/skaffold.Dockerfile index caf25026e2..94f2d65109 100644 --- a/hack/dev/skaffold.Dockerfile +++ b/hack/dev/skaffold.Dockerfile @@ -153,9 +153,9 @@ RUN curl -fsSL -o kustomize.tar.gz "${KUSTOMIZE5_URL}" \ ENV KOTS_HELM_BIN_DIR=/usr/local/bin # Install helm v3 -ENV HELM3_VERSION=3.12.2 +ENV HELM3_VERSION=3.13.0 ENV HELM3_URL=https://get.helm.sh/helm-v${HELM3_VERSION}-linux-amd64.tar.gz -ENV HELM3_SHA256SUM=2b6efaa009891d3703869f4be80ab86faa33fa83d9d5ff2f6492a8aebe97b219 +ENV HELM3_SHA256SUM=138676351483e61d12dfade70da6c03d471bbdcac84eaadeb5e1d06fa114a24f RUN cd /tmp && curl -fsSL -o helm.tar.gz "${HELM3_URL}" \ && echo "${HELM3_SHA256SUM} helm.tar.gz" | sha256sum -c - \ && tar -xzvf helm.tar.gz \ diff --git a/pkg/airgap/airgap.go b/pkg/airgap/airgap.go index 80dee5a4ff..083baa5217 100644 --- a/pkg/airgap/airgap.go +++ b/pkg/airgap/airgap.go @@ -227,6 +227,7 @@ func CreateAppFromAirgap(opts CreateAirgapAppOpts) (finalError error) { Password: opts.RegistryPassword, IsReadOnly: opts.RegistryIsReadOnly, }, + AppID: opts.PendingApp.ID, AppSlug: opts.PendingApp.Slug, AppSequence: 0, AppVersionLabel: instParams.AppVersionLabel, diff --git a/pkg/airgap/update.go b/pkg/airgap/update.go index f9e1b80e2a..945dd9cac7 100644 --- a/pkg/airgap/update.go +++ b/pkg/airgap/update.go @@ -173,6 +173,7 @@ func UpdateAppFromPath(a *apptypes.App, airgapRoot string, airgapBundlePath stri Silent: true, RewriteImages: true, RewriteImageOptions: registrySettings, + AppID: a.ID, AppSlug: a.Slug, AppSequence: appSequence, SkipCompatibilityCheck: skipCompatibilityCheck, diff --git a/pkg/apiserver/server.go b/pkg/apiserver/server.go index 076ebe095d..8938f0986f 100644 --- a/pkg/apiserver/server.go +++ b/pkg/apiserver/server.go @@ -89,9 +89,9 @@ func Start(params *APIServerParams) { if !util.IsHelmManaged() { client := &client.Client{ - TargetNamespace: util.AppNamespace(), - ExistingInformers: map[string]bool{}, - HookStopChans: []chan struct{}{}, + TargetNamespace: util.AppNamespace(), + ExistingHookInformers: map[string]bool{}, + HookStopChans: []chan struct{}{}, } store := store.GetStore() k8sClientset, err := k8sutil.GetClientset() diff --git a/pkg/apparchive/helm-v1beta2.go b/pkg/apparchive/helm-v1beta2.go index 7ef4cb7af6..69fa06fefc 100644 --- a/pkg/apparchive/helm-v1beta2.go +++ b/pkg/apparchive/helm-v1beta2.go @@ -73,6 +73,10 @@ type WriteV1Beta2HelmChartsOptions struct { // WriteV1Beta2HelmCharts copies the upstream helm chart archive and rendered values to the helm directory and processes online images (if necessary) func WriteV1Beta2HelmCharts(opts WriteV1Beta2HelmChartsOptions) error { + // clear the previous helm dir before writing + helmDir := opts.Upstream.GetHelmDir(opts.WriteUpstreamOptions) + os.RemoveAll(helmDir) + if opts.KotsKinds == nil || opts.KotsKinds.V1Beta2HelmCharts == nil { return nil } @@ -91,7 +95,7 @@ func WriteV1Beta2HelmCharts(opts WriteV1Beta2HelmChartsOptions) error { } } - chartDir := path.Join(opts.Upstream.GetHelmDir(opts.WriteUpstreamOptions), helmChart.GetDirName()) + chartDir := path.Join(helmDir, helmChart.GetDirName()) if err := os.MkdirAll(chartDir, 0744); err != nil { return errors.Wrap(err, "failed to create chart dir") } diff --git a/pkg/base/replicated.go b/pkg/base/replicated.go index 580cf1dac2..3e194ccda9 100644 --- a/pkg/base/replicated.go +++ b/pkg/base/replicated.go @@ -23,6 +23,7 @@ import ( kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" kotsv1beta2 "github.com/replicatedhq/kotskinds/apis/kots/v1beta2" kotsscheme "github.com/replicatedhq/kotskinds/client/kotsclientset/scheme" + "github.com/replicatedhq/kotskinds/pkg/helmchart" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" troubleshootscheme "github.com/replicatedhq/troubleshoot/pkg/client/troubleshootclientset/scheme" velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" @@ -502,7 +503,7 @@ func getKotsKinds(u *upstreamtypes.Upstream) (*kotsutil.KotsKinds, error) { // FindHelmChartArchiveInRelease iterates through all files in the release (upstreamFiles), looking for a helm chart archive // that matches the chart name and version specified in the kotsHelmChart parameter -func FindHelmChartArchiveInRelease(upstreamFiles []upstreamtypes.UpstreamFile, kotsHelmChart kotsutil.HelmChartInterface) ([]byte, error) { +func FindHelmChartArchiveInRelease(upstreamFiles []upstreamtypes.UpstreamFile, kotsHelmChart helmchart.HelmChartInterface) ([]byte, error) { for _, upstreamFile := range upstreamFiles { if !isHelmChart(upstreamFile.Content) { continue diff --git a/pkg/handlers/custom_metrics.go b/pkg/handlers/custom_metrics.go index 4f658ae7a2..64ad490260 100644 --- a/pkg/handlers/custom_metrics.go +++ b/pkg/handlers/custom_metrics.go @@ -79,6 +79,10 @@ func validateCustomMetricsData(data ApplicationMetricsData) error { for key, val := range data { valType := reflect.TypeOf(val) + if valType == nil { + return errors.Errorf("%s value is nil, only scalar values are allowed", key) + } + switch valType.Kind() { case reflect.Slice: return errors.Errorf("%s value is an array, only scalar values are allowed", key) diff --git a/pkg/handlers/custom_metrics_test.go b/pkg/handlers/custom_metrics_test.go index 8e0b3bda1b..d588738764 100644 --- a/pkg/handlers/custom_metrics_test.go +++ b/pkg/handlers/custom_metrics_test.go @@ -53,6 +53,14 @@ func Test_validateCustomMetricsData(t *testing.T) { }, wantErr: true, }, + { + name: "nil value", + data: ApplicationMetricsData{ + "key1": nil, + "key2": 4, + }, + wantErr: true, + }, } for _, test := range tests { diff --git a/pkg/handlers/handlers.go b/pkg/handlers/handlers.go index 11b2c708c2..d6b43a9b47 100644 --- a/pkg/handlers/handlers.go +++ b/pkg/handlers/handlers.go @@ -259,22 +259,35 @@ func RegisterSessionAuthRoutes(r *mux.Router, kotsStore store.Store, handler KOT HandlerFunc(middleware.EnforceAccess(policy.BackupRead, handler.GetVeleroStatus)) // KURL - r.Name("Kurl").Path("/api/v1/kurl").HandlerFunc(NotImplemented) // I'm not sure why this is here - r.Name("GenerateNodeJoinCommandWorker").Path("/api/v1/kurl/generate-node-join-command-worker").Methods("POST"). - HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateNodeJoinCommandWorker)) - r.Name("GenerateNodeJoinCommandMaster").Path("/api/v1/kurl/generate-node-join-command-master").Methods("POST"). - HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateNodeJoinCommandMaster)) - r.Name("GenerateNodeJoinCommandSecondary").Path("/api/v1/kurl/generate-node-join-command-secondary").Methods("POST"). - HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateNodeJoinCommandSecondary)) - r.Name("GenerateNodeJoinCommandPrimary").Path("/api/v1/kurl/generate-node-join-command-primary").Methods("POST"). - HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateNodeJoinCommandPrimary)) - r.Name("DrainNode").Path("/api/v1/kurl/nodes/{nodeName}/drain").Methods("POST"). - HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.DrainNode)) - r.Name("DeleteNode").Path("/api/v1/kurl/nodes/{nodeName}").Methods("DELETE"). - HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.DeleteNode)) + r.Name("Kurl").Path("/api/v1/kurl").HandlerFunc(NotImplemented) + r.Name("GenerateKurlNodeJoinCommandWorker").Path("/api/v1/kurl/generate-node-join-command-worker").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateKurlNodeJoinCommandWorker)) + r.Name("GenerateKurlNodeJoinCommandMaster").Path("/api/v1/kurl/generate-node-join-command-master").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateKurlNodeJoinCommandMaster)) + r.Name("GenerateKurlNodeJoinCommandSecondary").Path("/api/v1/kurl/generate-node-join-command-secondary").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateKurlNodeJoinCommandSecondary)) + r.Name("GenerateKurlNodeJoinCommandPrimary").Path("/api/v1/kurl/generate-node-join-command-primary").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateKurlNodeJoinCommandPrimary)) + r.Name("DrainKurlNode").Path("/api/v1/kurl/nodes/{nodeName}/drain").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.DrainKurlNode)) + r.Name("DeleteKurlNode").Path("/api/v1/kurl/nodes/{nodeName}").Methods("DELETE"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.DeleteKurlNode)) r.Name("GetKurlNodes").Path("/api/v1/kurl/nodes").Methods("GET"). HandlerFunc(middleware.EnforceAccess(policy.ClusterRead, handler.GetKurlNodes)) + // HelmVM + r.Name("HelmVM").Path("/api/v1/helmvm").HandlerFunc(NotImplemented) + r.Name("GenerateHelmVMNodeJoinCommandSecondary").Path("/api/v1/helmvm/generate-node-join-command-secondary").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateHelmVMNodeJoinCommandSecondary)) + r.Name("GenerateHelmVMNodeJoinCommandPrimary").Path("/api/v1/helmvm/generate-node-join-command-primary").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.GenerateHelmVMNodeJoinCommandPrimary)) + r.Name("DrainHelmVMNode").Path("/api/v1/helmvm/nodes/{nodeName}/drain").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.DrainHelmVMNode)) + r.Name("DeleteHelmVMNode").Path("/api/v1/helmvm/nodes/{nodeName}").Methods("DELETE"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterWrite, handler.DeleteHelmVMNode)) + r.Name("GetHelmVMNodes").Path("/api/v1/helmvm/nodes").Methods("GET"). + HandlerFunc(middleware.EnforceAccess(policy.ClusterRead, handler.GetHelmVMNodes)) + // Prometheus r.Name("SetPrometheusAddress").Path("/api/v1/prometheus").Methods("POST"). HandlerFunc(middleware.EnforceAccess(policy.PrometheussettingsWrite, handler.SetPrometheusAddress)) diff --git a/pkg/handlers/handlers_test.go b/pkg/handlers/handlers_test.go index 7b15466cc9..91bd7c0731 100644 --- a/pkg/handlers/handlers_test.go +++ b/pkg/handlers/handlers_test.go @@ -1136,64 +1136,64 @@ var HandlerPolicyTests = map[string][]HandlerPolicyTest{ }, "Kurl": {}, // Not implemented - "GenerateNodeJoinCommandWorker": { + "GenerateKurlNodeJoinCommandWorker": { { Roles: []rbactypes.Role{rbac.ClusterAdminRole}, SessionRoles: []string{rbac.ClusterAdminRoleID}, Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { - handlerRecorder.GenerateNodeJoinCommandWorker(gomock.Any(), gomock.Any()) + handlerRecorder.GenerateKurlNodeJoinCommandWorker(gomock.Any(), gomock.Any()) }, ExpectStatus: http.StatusOK, }, }, - "GenerateNodeJoinCommandMaster": { + "GenerateKurlNodeJoinCommandMaster": { { Roles: []rbactypes.Role{rbac.ClusterAdminRole}, SessionRoles: []string{rbac.ClusterAdminRoleID}, Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { - handlerRecorder.GenerateNodeJoinCommandMaster(gomock.Any(), gomock.Any()) + handlerRecorder.GenerateKurlNodeJoinCommandMaster(gomock.Any(), gomock.Any()) }, ExpectStatus: http.StatusOK, }, }, - "GenerateNodeJoinCommandSecondary": { + "GenerateKurlNodeJoinCommandSecondary": { { Roles: []rbactypes.Role{rbac.ClusterAdminRole}, SessionRoles: []string{rbac.ClusterAdminRoleID}, Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { - handlerRecorder.GenerateNodeJoinCommandSecondary(gomock.Any(), gomock.Any()) + handlerRecorder.GenerateKurlNodeJoinCommandSecondary(gomock.Any(), gomock.Any()) }, ExpectStatus: http.StatusOK, }, }, - "GenerateNodeJoinCommandPrimary": { + "GenerateKurlNodeJoinCommandPrimary": { { Roles: []rbactypes.Role{rbac.ClusterAdminRole}, SessionRoles: []string{rbac.ClusterAdminRoleID}, Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { - handlerRecorder.GenerateNodeJoinCommandPrimary(gomock.Any(), gomock.Any()) + handlerRecorder.GenerateKurlNodeJoinCommandPrimary(gomock.Any(), gomock.Any()) }, ExpectStatus: http.StatusOK, }, }, - "DrainNode": { + "DrainKurlNode": { { Vars: map[string]string{"nodeName": "node-name"}, Roles: []rbactypes.Role{rbac.ClusterAdminRole}, SessionRoles: []string{rbac.ClusterAdminRoleID}, Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { - handlerRecorder.DrainNode(gomock.Any(), gomock.Any()) + handlerRecorder.DrainKurlNode(gomock.Any(), gomock.Any()) }, ExpectStatus: http.StatusOK, }, }, - "DeleteNode": { + "DeleteKurlNode": { { Vars: map[string]string{"nodeName": "node-name"}, Roles: []rbactypes.Role{rbac.ClusterAdminRole}, SessionRoles: []string{rbac.ClusterAdminRoleID}, Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { - handlerRecorder.DeleteNode(gomock.Any(), gomock.Any()) + handlerRecorder.DeleteKurlNode(gomock.Any(), gomock.Any()) }, ExpectStatus: http.StatusOK, }, @@ -1209,6 +1209,60 @@ var HandlerPolicyTests = map[string][]HandlerPolicyTest{ }, }, + "HelmVM": {}, // Not implemented + "GenerateHelmVMNodeJoinCommandSecondary": { + { + Roles: []rbactypes.Role{rbac.ClusterAdminRole}, + SessionRoles: []string{rbac.ClusterAdminRoleID}, + Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { + handlerRecorder.GenerateHelmVMNodeJoinCommandSecondary(gomock.Any(), gomock.Any()) + }, + ExpectStatus: http.StatusOK, + }, + }, + "GenerateHelmVMNodeJoinCommandPrimary": { + { + Roles: []rbactypes.Role{rbac.ClusterAdminRole}, + SessionRoles: []string{rbac.ClusterAdminRoleID}, + Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { + handlerRecorder.GenerateHelmVMNodeJoinCommandPrimary(gomock.Any(), gomock.Any()) + }, + ExpectStatus: http.StatusOK, + }, + }, + "DrainHelmVMNode": { + { + Vars: map[string]string{"nodeName": "node-name"}, + Roles: []rbactypes.Role{rbac.ClusterAdminRole}, + SessionRoles: []string{rbac.ClusterAdminRoleID}, + Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { + handlerRecorder.DrainHelmVMNode(gomock.Any(), gomock.Any()) + }, + ExpectStatus: http.StatusOK, + }, + }, + "DeleteHelmVMNode": { + { + Vars: map[string]string{"nodeName": "node-name"}, + Roles: []rbactypes.Role{rbac.ClusterAdminRole}, + SessionRoles: []string{rbac.ClusterAdminRoleID}, + Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { + handlerRecorder.DeleteHelmVMNode(gomock.Any(), gomock.Any()) + }, + ExpectStatus: http.StatusOK, + }, + }, + "GetHelmVMNodes": { + { + Roles: []rbactypes.Role{rbac.ClusterAdminRole}, + SessionRoles: []string{rbac.ClusterAdminRoleID}, + Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { + handlerRecorder.GetHelmVMNodes(gomock.Any(), gomock.Any()) + }, + ExpectStatus: http.StatusOK, + }, + }, + // Prometheus "SetPrometheusAddress": { { diff --git a/pkg/handlers/helmvm_delete_node.go b/pkg/handlers/helmvm_delete_node.go new file mode 100644 index 0000000000..1b732ab07f --- /dev/null +++ b/pkg/handlers/helmvm_delete_node.go @@ -0,0 +1,50 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/gorilla/mux" + "github.com/replicatedhq/kots/pkg/helmvm" + "github.com/replicatedhq/kots/pkg/k8sutil" + "github.com/replicatedhq/kots/pkg/logger" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func (h *Handler) DeleteHelmVMNode(w http.ResponseWriter, r *http.Request) { + client, err := k8sutil.GetClientset() + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + restconfig, err := k8sutil.GetClusterConfig() + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + ctx := context.Background() + nodeName := mux.Vars(r)["nodeName"] + node, err := client.CoreV1().Nodes().Get(ctx, nodeName, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + logger.Errorf("Failed to delete node %s: not found", nodeName) + w.WriteHeader(http.StatusNotFound) + return + } + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + if err := helmvm.DeleteNode(ctx, client, restconfig, node); err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + logger.Infof("Node %s successfully deleted", node.Name) +} diff --git a/pkg/handlers/helmvm_drain_node.go b/pkg/handlers/helmvm_drain_node.go new file mode 100644 index 0000000000..ae0a337f6f --- /dev/null +++ b/pkg/handlers/helmvm_drain_node.go @@ -0,0 +1,45 @@ +package handlers + +import ( + "context" + "net/http" + + "github.com/gorilla/mux" + "github.com/replicatedhq/kots/pkg/helmvm" + "github.com/replicatedhq/kots/pkg/k8sutil" + "github.com/replicatedhq/kots/pkg/logger" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func (h *Handler) DrainHelmVMNode(w http.ResponseWriter, r *http.Request) { + client, err := k8sutil.GetClientset() + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + ctx := context.Background() + nodeName := mux.Vars(r)["nodeName"] + node, err := client.CoreV1().Nodes().Get(ctx, nodeName, metav1.GetOptions{}) + if err != nil { + if errors.IsNotFound(err) { + logger.Errorf("Failed to drain node %s: not found", nodeName) + w.WriteHeader(http.StatusNotFound) + return + } + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + // This pod may get evicted and not be able to respond to the request + go func() { + if err := helmvm.DrainNode(ctx, client, node); err != nil { + logger.Error(err) + return + } + logger.Infof("Node %s successfully drained", node.Name) + }() +} diff --git a/pkg/handlers/helmvm_get.go b/pkg/handlers/helmvm_get.go new file mode 100644 index 0000000000..cd440d116f --- /dev/null +++ b/pkg/handlers/helmvm_get.go @@ -0,0 +1,26 @@ +package handlers + +import ( + "net/http" + + "github.com/replicatedhq/kots/pkg/helmvm" + "github.com/replicatedhq/kots/pkg/k8sutil" + "github.com/replicatedhq/kots/pkg/logger" +) + +func (h *Handler) GetHelmVMNodes(w http.ResponseWriter, r *http.Request) { + client, err := k8sutil.GetClientset() + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + nodes, err := helmvm.GetNodes(client) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + JSON(w, http.StatusOK, nodes) +} diff --git a/pkg/handlers/helmvm_node_join_command.go b/pkg/handlers/helmvm_node_join_command.go new file mode 100644 index 0000000000..6604b659d9 --- /dev/null +++ b/pkg/handlers/helmvm_node_join_command.go @@ -0,0 +1,55 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/replicatedhq/kots/pkg/helmvm" + "github.com/replicatedhq/kots/pkg/k8sutil" + "github.com/replicatedhq/kots/pkg/logger" +) + +type GenerateHelmVMNodeJoinCommandResponse struct { + Command []string `json:"command"` + Expiry string `json:"expiry"` +} + +func (h *Handler) GenerateHelmVMNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) { + client, err := k8sutil.GetClientset() + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + command, expiry, err := helmvm.GenerateAddNodeCommand(client, false) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + JSON(w, http.StatusOK, GenerateHelmVMNodeJoinCommandResponse{ + Command: command, + Expiry: expiry.Format(time.RFC3339), + }) +} + +func (h *Handler) GenerateHelmVMNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) { + client, err := k8sutil.GetClientset() + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + command, expiry, err := helmvm.GenerateAddNodeCommand(client, true) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + JSON(w, http.StatusOK, GenerateHelmVMNodeJoinCommandResponse{ + Command: command, + Expiry: expiry.Format(time.RFC3339), + }) +} diff --git a/pkg/handlers/interface.go b/pkg/handlers/interface.go index 40d7f22bdf..69b3539288 100644 --- a/pkg/handlers/interface.go +++ b/pkg/handlers/interface.go @@ -130,14 +130,21 @@ type KOTSHandler interface { GetVeleroStatus(w http.ResponseWriter, r *http.Request) // KURL - GenerateNodeJoinCommandWorker(w http.ResponseWriter, r *http.Request) - GenerateNodeJoinCommandMaster(w http.ResponseWriter, r *http.Request) - GenerateNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) - GenerateNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) - DrainNode(w http.ResponseWriter, r *http.Request) - DeleteNode(w http.ResponseWriter, r *http.Request) + GenerateKurlNodeJoinCommandWorker(w http.ResponseWriter, r *http.Request) + GenerateKurlNodeJoinCommandMaster(w http.ResponseWriter, r *http.Request) + GenerateKurlNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) + GenerateKurlNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) + DrainKurlNode(w http.ResponseWriter, r *http.Request) + DeleteKurlNode(w http.ResponseWriter, r *http.Request) GetKurlNodes(w http.ResponseWriter, r *http.Request) + // HelmVM + GenerateHelmVMNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) + GenerateHelmVMNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) + DrainHelmVMNode(w http.ResponseWriter, r *http.Request) + DeleteHelmVMNode(w http.ResponseWriter, r *http.Request) + GetHelmVMNodes(w http.ResponseWriter, r *http.Request) + // Prometheus SetPrometheusAddress(w http.ResponseWriter, r *http.Request) diff --git a/pkg/handlers/kurl_delete_node.go b/pkg/handlers/kurl_delete_node.go index 02f075d88f..1edf8ac46f 100644 --- a/pkg/handlers/kurl_delete_node.go +++ b/pkg/handlers/kurl_delete_node.go @@ -13,7 +13,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -func (h *Handler) DeleteNode(w http.ResponseWriter, r *http.Request) { +func (h *Handler) DeleteKurlNode(w http.ResponseWriter, r *http.Request) { client, err := k8sutil.GetClientset() if err != nil { logger.Error(err) diff --git a/pkg/handlers/kurl_drain_node.go b/pkg/handlers/kurl_drain_node.go index 98809a9e7a..80e58b05d1 100644 --- a/pkg/handlers/kurl_drain_node.go +++ b/pkg/handlers/kurl_drain_node.go @@ -12,7 +12,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -func (h *Handler) DrainNode(w http.ResponseWriter, r *http.Request) { +func (h *Handler) DrainKurlNode(w http.ResponseWriter, r *http.Request) { client, err := k8sutil.GetClientset() if err != nil { logger.Error(err) diff --git a/pkg/handlers/kurl_node_join_command.go b/pkg/handlers/kurl_node_join_command.go index 6d1bdfa1b9..1f0ed4a438 100644 --- a/pkg/handlers/kurl_node_join_command.go +++ b/pkg/handlers/kurl_node_join_command.go @@ -9,12 +9,12 @@ import ( "github.com/replicatedhq/kots/pkg/logger" ) -type GenerateNodeJoinCommandResponse struct { +type GenerateKurlNodeJoinCommandResponse struct { Command []string `json:"command"` Expiry string `json:"expiry"` } -func (h *Handler) GenerateNodeJoinCommandWorker(w http.ResponseWriter, r *http.Request) { +func (h *Handler) GenerateKurlNodeJoinCommandWorker(w http.ResponseWriter, r *http.Request) { client, err := k8sutil.GetClientset() if err != nil { logger.Error(err) @@ -28,13 +28,13 @@ func (h *Handler) GenerateNodeJoinCommandWorker(w http.ResponseWriter, r *http.R w.WriteHeader(http.StatusInternalServerError) return } - JSON(w, http.StatusOK, GenerateNodeJoinCommandResponse{ + JSON(w, http.StatusOK, GenerateKurlNodeJoinCommandResponse{ Command: command, Expiry: expiry.Format(time.RFC3339), }) } -func (h *Handler) GenerateNodeJoinCommandMaster(w http.ResponseWriter, r *http.Request) { +func (h *Handler) GenerateKurlNodeJoinCommandMaster(w http.ResponseWriter, r *http.Request) { client, err := k8sutil.GetClientset() if err != nil { logger.Error(err) @@ -48,13 +48,13 @@ func (h *Handler) GenerateNodeJoinCommandMaster(w http.ResponseWriter, r *http.R w.WriteHeader(http.StatusInternalServerError) return } - JSON(w, http.StatusOK, GenerateNodeJoinCommandResponse{ + JSON(w, http.StatusOK, GenerateKurlNodeJoinCommandResponse{ Command: command, Expiry: expiry.Format(time.RFC3339), }) } -func (h *Handler) GenerateNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) { +func (h *Handler) GenerateKurlNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) { client, err := k8sutil.GetClientset() if err != nil { logger.Error(err) @@ -68,13 +68,13 @@ func (h *Handler) GenerateNodeJoinCommandSecondary(w http.ResponseWriter, r *htt w.WriteHeader(http.StatusInternalServerError) return } - JSON(w, http.StatusOK, GenerateNodeJoinCommandResponse{ + JSON(w, http.StatusOK, GenerateKurlNodeJoinCommandResponse{ Command: command, Expiry: expiry.Format(time.RFC3339), }) } -func (h *Handler) GenerateNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) { +func (h *Handler) GenerateKurlNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) { client, err := k8sutil.GetClientset() if err != nil { logger.Error(err) @@ -88,7 +88,7 @@ func (h *Handler) GenerateNodeJoinCommandPrimary(w http.ResponseWriter, r *http. w.WriteHeader(http.StatusInternalServerError) return } - JSON(w, http.StatusOK, GenerateNodeJoinCommandResponse{ + JSON(w, http.StatusOK, GenerateKurlNodeJoinCommandResponse{ Command: command, Expiry: expiry.Format(time.RFC3339), }) diff --git a/pkg/handlers/metadata.go b/pkg/handlers/metadata.go index 37952c971a..81903b26dd 100644 --- a/pkg/handlers/metadata.go +++ b/pkg/handlers/metadata.go @@ -52,6 +52,7 @@ type MetadataResponseBranding struct { type AdminConsoleMetadata struct { IsAirgap bool `json:"isAirgap"` IsKurl bool `json:"isKurl"` + IsHelmVM bool `json:"isHelmVM"` } // GetMetadataHandler helper function that returns a http handler func that returns metadata. It takes a function that @@ -72,6 +73,7 @@ func GetMetadataHandler(getK8sInfoFn MetadataK8sFn, kotsStore store.Store) http. if kuberneteserrors.IsNotFound(err) { metadataResponse.AdminConsoleMetadata.IsAirgap = kotsadmMetadata.IsAirgap metadataResponse.AdminConsoleMetadata.IsKurl = kotsadmMetadata.IsKurl + metadataResponse.AdminConsoleMetadata.IsHelmVM = kotsadmMetadata.IsHelmVM logger.Info(fmt.Sprintf("config map %q not found", metadataConfigMapName)) JSON(w, http.StatusOK, &metadataResponse) @@ -114,6 +116,7 @@ func GetMetadataHandler(getK8sInfoFn MetadataK8sFn, kotsStore store.Store) http. metadataResponse.AdminConsoleMetadata = AdminConsoleMetadata{ IsAirgap: kotsadmMetadata.IsAirgap, IsKurl: kotsadmMetadata.IsKurl, + IsHelmVM: kotsadmMetadata.IsHelmVM, } JSON(w, http.StatusOK, metadataResponse) diff --git a/pkg/handlers/mock/mock.go b/pkg/handlers/mock/mock.go index 45f4d1c242..7440665850 100644 --- a/pkg/handlers/mock/mock.go +++ b/pkg/handlers/mock/mock.go @@ -262,16 +262,28 @@ func (mr *MockKOTSHandlerMockRecorder) DeleteBackup(w, r interface{}) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteBackup", reflect.TypeOf((*MockKOTSHandler)(nil).DeleteBackup), w, r) } -// DeleteNode mocks base method. -func (m *MockKOTSHandler) DeleteNode(w http.ResponseWriter, r *http.Request) { +// DeleteHelmVMNode mocks base method. +func (m *MockKOTSHandler) DeleteHelmVMNode(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() - m.ctrl.Call(m, "DeleteNode", w, r) + m.ctrl.Call(m, "DeleteHelmVMNode", w, r) } -// DeleteNode indicates an expected call of DeleteNode. -func (mr *MockKOTSHandlerMockRecorder) DeleteNode(w, r interface{}) *gomock.Call { +// DeleteHelmVMNode indicates an expected call of DeleteHelmVMNode. +func (mr *MockKOTSHandlerMockRecorder) DeleteHelmVMNode(w, r interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNode", reflect.TypeOf((*MockKOTSHandler)(nil).DeleteNode), w, r) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteHelmVMNode", reflect.TypeOf((*MockKOTSHandler)(nil).DeleteHelmVMNode), w, r) +} + +// DeleteKurlNode mocks base method. +func (m *MockKOTSHandler) DeleteKurlNode(w http.ResponseWriter, r *http.Request) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "DeleteKurlNode", w, r) +} + +// DeleteKurlNode indicates an expected call of DeleteKurlNode. +func (mr *MockKOTSHandlerMockRecorder) DeleteKurlNode(w, r interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteKurlNode", reflect.TypeOf((*MockKOTSHandler)(nil).DeleteKurlNode), w, r) } // DeleteRedact mocks base method. @@ -382,16 +394,28 @@ func (mr *MockKOTSHandlerMockRecorder) DownloadSupportBundle(w, r interface{}) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DownloadSupportBundle", reflect.TypeOf((*MockKOTSHandler)(nil).DownloadSupportBundle), w, r) } -// DrainNode mocks base method. -func (m *MockKOTSHandler) DrainNode(w http.ResponseWriter, r *http.Request) { +// DrainHelmVMNode mocks base method. +func (m *MockKOTSHandler) DrainHelmVMNode(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() - m.ctrl.Call(m, "DrainNode", w, r) + m.ctrl.Call(m, "DrainHelmVMNode", w, r) } -// DrainNode indicates an expected call of DrainNode. -func (mr *MockKOTSHandlerMockRecorder) DrainNode(w, r interface{}) *gomock.Call { +// DrainHelmVMNode indicates an expected call of DrainHelmVMNode. +func (mr *MockKOTSHandlerMockRecorder) DrainHelmVMNode(w, r interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DrainNode", reflect.TypeOf((*MockKOTSHandler)(nil).DrainNode), w, r) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DrainHelmVMNode", reflect.TypeOf((*MockKOTSHandler)(nil).DrainHelmVMNode), w, r) +} + +// DrainKurlNode mocks base method. +func (m *MockKOTSHandler) DrainKurlNode(w http.ResponseWriter, r *http.Request) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "DrainKurlNode", w, r) +} + +// DrainKurlNode indicates an expected call of DrainKurlNode. +func (mr *MockKOTSHandlerMockRecorder) DrainKurlNode(w, r interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DrainKurlNode", reflect.TypeOf((*MockKOTSHandler)(nil).DrainKurlNode), w, r) } // ExchangePlatformLicense mocks base method. @@ -418,52 +442,76 @@ func (mr *MockKOTSHandlerMockRecorder) GarbageCollectImages(w, r interface{}) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GarbageCollectImages", reflect.TypeOf((*MockKOTSHandler)(nil).GarbageCollectImages), w, r) } -// GenerateNodeJoinCommandMaster mocks base method. -func (m *MockKOTSHandler) GenerateNodeJoinCommandMaster(w http.ResponseWriter, r *http.Request) { +// GenerateHelmVMNodeJoinCommandPrimary mocks base method. +func (m *MockKOTSHandler) GenerateHelmVMNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "GenerateHelmVMNodeJoinCommandPrimary", w, r) +} + +// GenerateHelmVMNodeJoinCommandPrimary indicates an expected call of GenerateHelmVMNodeJoinCommandPrimary. +func (mr *MockKOTSHandlerMockRecorder) GenerateHelmVMNodeJoinCommandPrimary(w, r interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateHelmVMNodeJoinCommandPrimary", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateHelmVMNodeJoinCommandPrimary), w, r) +} + +// GenerateHelmVMNodeJoinCommandSecondary mocks base method. +func (m *MockKOTSHandler) GenerateHelmVMNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() - m.ctrl.Call(m, "GenerateNodeJoinCommandMaster", w, r) + m.ctrl.Call(m, "GenerateHelmVMNodeJoinCommandSecondary", w, r) } -// GenerateNodeJoinCommandMaster indicates an expected call of GenerateNodeJoinCommandMaster. -func (mr *MockKOTSHandlerMockRecorder) GenerateNodeJoinCommandMaster(w, r interface{}) *gomock.Call { +// GenerateHelmVMNodeJoinCommandSecondary indicates an expected call of GenerateHelmVMNodeJoinCommandSecondary. +func (mr *MockKOTSHandlerMockRecorder) GenerateHelmVMNodeJoinCommandSecondary(w, r interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateNodeJoinCommandMaster", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateNodeJoinCommandMaster), w, r) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateHelmVMNodeJoinCommandSecondary", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateHelmVMNodeJoinCommandSecondary), w, r) } -// GenerateNodeJoinCommandPrimary mocks base method. -func (m *MockKOTSHandler) GenerateNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) { +// GenerateKurlNodeJoinCommandMaster mocks base method. +func (m *MockKOTSHandler) GenerateKurlNodeJoinCommandMaster(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() - m.ctrl.Call(m, "GenerateNodeJoinCommandPrimary", w, r) + m.ctrl.Call(m, "GenerateKurlNodeJoinCommandMaster", w, r) } -// GenerateNodeJoinCommandPrimary indicates an expected call of GenerateNodeJoinCommandPrimary. -func (mr *MockKOTSHandlerMockRecorder) GenerateNodeJoinCommandPrimary(w, r interface{}) *gomock.Call { +// GenerateKurlNodeJoinCommandMaster indicates an expected call of GenerateKurlNodeJoinCommandMaster. +func (mr *MockKOTSHandlerMockRecorder) GenerateKurlNodeJoinCommandMaster(w, r interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateNodeJoinCommandPrimary", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateNodeJoinCommandPrimary), w, r) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateKurlNodeJoinCommandMaster", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateKurlNodeJoinCommandMaster), w, r) } -// GenerateNodeJoinCommandSecondary mocks base method. -func (m *MockKOTSHandler) GenerateNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) { +// GenerateKurlNodeJoinCommandPrimary mocks base method. +func (m *MockKOTSHandler) GenerateKurlNodeJoinCommandPrimary(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() - m.ctrl.Call(m, "GenerateNodeJoinCommandSecondary", w, r) + m.ctrl.Call(m, "GenerateKurlNodeJoinCommandPrimary", w, r) } -// GenerateNodeJoinCommandSecondary indicates an expected call of GenerateNodeJoinCommandSecondary. -func (mr *MockKOTSHandlerMockRecorder) GenerateNodeJoinCommandSecondary(w, r interface{}) *gomock.Call { +// GenerateKurlNodeJoinCommandPrimary indicates an expected call of GenerateKurlNodeJoinCommandPrimary. +func (mr *MockKOTSHandlerMockRecorder) GenerateKurlNodeJoinCommandPrimary(w, r interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateNodeJoinCommandSecondary", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateNodeJoinCommandSecondary), w, r) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateKurlNodeJoinCommandPrimary", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateKurlNodeJoinCommandPrimary), w, r) } -// GenerateNodeJoinCommandWorker mocks base method. -func (m *MockKOTSHandler) GenerateNodeJoinCommandWorker(w http.ResponseWriter, r *http.Request) { +// GenerateKurlNodeJoinCommandSecondary mocks base method. +func (m *MockKOTSHandler) GenerateKurlNodeJoinCommandSecondary(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() - m.ctrl.Call(m, "GenerateNodeJoinCommandWorker", w, r) + m.ctrl.Call(m, "GenerateKurlNodeJoinCommandSecondary", w, r) } -// GenerateNodeJoinCommandWorker indicates an expected call of GenerateNodeJoinCommandWorker. -func (mr *MockKOTSHandlerMockRecorder) GenerateNodeJoinCommandWorker(w, r interface{}) *gomock.Call { +// GenerateKurlNodeJoinCommandSecondary indicates an expected call of GenerateKurlNodeJoinCommandSecondary. +func (mr *MockKOTSHandlerMockRecorder) GenerateKurlNodeJoinCommandSecondary(w, r interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateNodeJoinCommandWorker", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateNodeJoinCommandWorker), w, r) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateKurlNodeJoinCommandSecondary", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateKurlNodeJoinCommandSecondary), w, r) +} + +// GenerateKurlNodeJoinCommandWorker mocks base method. +func (m *MockKOTSHandler) GenerateKurlNodeJoinCommandWorker(w http.ResponseWriter, r *http.Request) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "GenerateKurlNodeJoinCommandWorker", w, r) +} + +// GenerateKurlNodeJoinCommandWorker indicates an expected call of GenerateKurlNodeJoinCommandWorker. +func (mr *MockKOTSHandlerMockRecorder) GenerateKurlNodeJoinCommandWorker(w, r interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateKurlNodeJoinCommandWorker", reflect.TypeOf((*MockKOTSHandler)(nil).GenerateKurlNodeJoinCommandWorker), w, r) } // GetAdminConsoleUpdateStatus mocks base method. @@ -706,6 +754,18 @@ func (mr *MockKOTSHandlerMockRecorder) GetGlobalSnapshotSettings(w, r interface{ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetGlobalSnapshotSettings", reflect.TypeOf((*MockKOTSHandler)(nil).GetGlobalSnapshotSettings), w, r) } +// GetHelmVMNodes mocks base method. +func (m *MockKOTSHandler) GetHelmVMNodes(w http.ResponseWriter, r *http.Request) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "GetHelmVMNodes", w, r) +} + +// GetHelmVMNodes indicates an expected call of GetHelmVMNodes. +func (mr *MockKOTSHandlerMockRecorder) GetHelmVMNodes(w, r interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHelmVMNodes", reflect.TypeOf((*MockKOTSHandler)(nil).GetHelmVMNodes), w, r) +} + // GetIdentityServiceConfig mocks base method. func (m *MockKOTSHandler) GetIdentityServiceConfig(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() diff --git a/pkg/helmvm/delete_node.go b/pkg/helmvm/delete_node.go new file mode 100644 index 0000000000..24d7e5e46d --- /dev/null +++ b/pkg/helmvm/delete_node.go @@ -0,0 +1,13 @@ +package helmvm + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +func DeleteNode(ctx context.Context, client kubernetes.Interface, restconfig *rest.Config, node *corev1.Node) error { + return nil +} diff --git a/pkg/helmvm/drain_node.go b/pkg/helmvm/drain_node.go new file mode 100644 index 0000000000..b8fa55afbb --- /dev/null +++ b/pkg/helmvm/drain_node.go @@ -0,0 +1,12 @@ +package helmvm + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes" +) + +func DrainNode(ctx context.Context, client kubernetes.Interface, node *corev1.Node) error { + return nil +} diff --git a/pkg/helmvm/exec.go b/pkg/helmvm/exec.go new file mode 100644 index 0000000000..04f94635de --- /dev/null +++ b/pkg/helmvm/exec.go @@ -0,0 +1,11 @@ +package helmvm + +import ( + corev1client "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/rest" +) + +// SyncExec returns exitcode, stdout, stderr. A non-zero exit code from the command is not considered an error. +func SyncExec(coreClient corev1client.CoreV1Interface, clientConfig *rest.Config, ns, pod, container string, command ...string) (int, string, string, error) { + return 0, "", "", nil +} diff --git a/pkg/helmvm/helmvm_nodes.go b/pkg/helmvm/helmvm_nodes.go new file mode 100644 index 0000000000..e00dca2108 --- /dev/null +++ b/pkg/helmvm/helmvm_nodes.go @@ -0,0 +1,212 @@ +package helmvm + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "math" + "net/http" + "os" + "strconv" + "time" + + "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/helmvm/types" + "github.com/replicatedhq/kots/pkg/logger" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + statsv1alpha1 "k8s.io/kubelet/pkg/apis/stats/v1alpha1" +) + +// GetNodes will get a list of nodes with stats +func GetNodes(client kubernetes.Interface) (*types.HelmVMNodes, error) { + nodes, err := client.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, errors.Wrap(err, "list nodes") + } + + toReturn := types.HelmVMNodes{} + + for _, node := range nodes.Items { + cpuCapacity := types.CapacityAvailable{} + memoryCapacity := types.CapacityAvailable{} + podCapacity := types.CapacityAvailable{} + + memoryCapacity.Capacity = float64(node.Status.Capacity.Memory().Value()) / math.Pow(2, 30) // capacity in GB + + cpuCapacity.Capacity, err = strconv.ParseFloat(node.Status.Capacity.Cpu().String(), 64) + if err != nil { + return nil, errors.Wrapf(err, "parse CPU capacity %q for node %s", node.Status.Capacity.Cpu().String(), node.Name) + } + + podCapacity.Capacity = float64(node.Status.Capacity.Pods().Value()) + + nodeIP := "" + for _, address := range node.Status.Addresses { + if address.Type == corev1.NodeInternalIP { + nodeIP = address.Address + } + } + + if nodeIP == "" { + logger.Infof("Did not find address for node %s, %+v", node.Name, node.Status.Addresses) + } else { + nodeMetrics, err := getNodeMetrics(nodeIP) + if err != nil { + logger.Infof("Got error retrieving stats for node %q: %v", node.Name, err) + } else { + if nodeMetrics.Node.Memory != nil && nodeMetrics.Node.Memory.AvailableBytes != nil { + memoryCapacity.Available = float64(*nodeMetrics.Node.Memory.AvailableBytes) / math.Pow(2, 30) + } + + if nodeMetrics.Node.CPU != nil && nodeMetrics.Node.CPU.UsageNanoCores != nil { + cpuCapacity.Available = cpuCapacity.Capacity - (float64(*nodeMetrics.Node.CPU.UsageNanoCores) / math.Pow(10, 9)) + } + + podCapacity.Available = podCapacity.Capacity - float64(len(nodeMetrics.Pods)) + } + } + + nodeLabelArray := []string{} + for k, v := range node.Labels { + nodeLabelArray = append(nodeLabelArray, fmt.Sprintf("%s:%s", k, v)) + } + + toReturn.Nodes = append(toReturn.Nodes, types.Node{ + Name: node.Name, + IsConnected: isConnected(node), + IsReady: isReady(node), + IsPrimaryNode: isPrimary(node), + CanDelete: node.Spec.Unschedulable && !isConnected(node), + KubeletVersion: node.Status.NodeInfo.KubeletVersion, + CPU: cpuCapacity, + Memory: memoryCapacity, + Pods: podCapacity, + Labels: nodeLabelArray, + Conditions: findNodeConditions(node.Status.Conditions), + }) + } + + isHelmVM, err := IsHelmVM(client) + if err != nil { + return nil, errors.Wrap(err, "is helmvm") + } + toReturn.IsHelmVMEnabled = isHelmVM + + isHA, err := IsHA(client) + if err != nil { + return nil, errors.Wrap(err, "is ha") + } + toReturn.HA = isHA + + return &toReturn, nil +} + +func findNodeConditions(conditions []corev1.NodeCondition) types.NodeConditions { + discoveredConditions := types.NodeConditions{} + for _, condition := range conditions { + if condition.Type == "MemoryPressure" { + discoveredConditions.MemoryPressure = condition.Status == corev1.ConditionTrue + } + if condition.Type == "DiskPressure" { + discoveredConditions.DiskPressure = condition.Status == corev1.ConditionTrue + } + if condition.Type == "PIDPressure" { + discoveredConditions.PidPressure = condition.Status == corev1.ConditionTrue + } + if condition.Type == "Ready" { + discoveredConditions.Ready = condition.Status == corev1.ConditionTrue + } + } + return discoveredConditions +} + +// get kubelet PKI info from /etc/kubernetes/pki/kubelet, use it to hit metrics server at `http://${nodeIP}:10255/stats/summary` +func getNodeMetrics(nodeIP string) (*statsv1alpha1.Summary, error) { + client := http.Client{ + Timeout: time.Second, + } + port := 10255 + + // only use mutual TLS if client cert exists + _, err := os.ReadFile("/etc/kubernetes/pki/kubelet/client.crt") + if err == nil { + cert, err := tls.LoadX509KeyPair("/etc/kubernetes/pki/kubelet/client.crt", "/etc/kubernetes/pki/kubelet/client.key") + if err != nil { + return nil, errors.Wrap(err, "get client keypair") + } + + // this will leak memory + client.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + Certificates: []tls.Certificate{cert}, + InsecureSkipVerify: true, + }, + } + port = 10250 + } + + r, err := client.Get(fmt.Sprintf("https://%s:%d/stats/summary", nodeIP, port)) + if err != nil { + return nil, errors.Wrapf(err, "get node %s stats", nodeIP) + } + defer r.Body.Close() + + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, errors.Wrapf(err, "read node %s stats response", nodeIP) + } + + summary := statsv1alpha1.Summary{} + err = json.Unmarshal(body, &summary) + if err != nil { + return nil, errors.Wrapf(err, "parse node %s stats response", nodeIP) + } + + return &summary, nil +} + +func isConnected(node corev1.Node) bool { + for _, taint := range node.Spec.Taints { + if taint.Key == "node.kubernetes.io/unreachable" { + return false + } + } + + return true +} + +func isReady(node corev1.Node) bool { + for _, condition := range node.Status.Conditions { + if condition.Type == "Ready" { + return condition.Status == corev1.ConditionTrue + } + } + + return false +} + +func isPrimary(node corev1.Node) bool { + for label := range node.ObjectMeta.Labels { + if label == "node-role.kubernetes.io/master" { + return true + } + if label == "node-role.kubernetes.io/control-plane" { + return true + } + } + + return false +} + +func internalIP(node corev1.Node) string { + for _, address := range node.Status.Addresses { + if address.Type == corev1.NodeInternalIP { + return address.Address + } + } + return "" +} diff --git a/pkg/helmvm/node_join.go b/pkg/helmvm/node_join.go new file mode 100644 index 0000000000..6aad6255a9 --- /dev/null +++ b/pkg/helmvm/node_join.go @@ -0,0 +1,12 @@ +package helmvm + +import ( + "time" + + "k8s.io/client-go/kubernetes" +) + +// GenerateAddNodeCommand will generate the HelmVM node add command for a primary or secondary node +func GenerateAddNodeCommand(client kubernetes.Interface, primary bool) ([]string, *time.Time, error) { + return nil, nil, nil +} diff --git a/pkg/helmvm/types/types.go b/pkg/helmvm/types/types.go new file mode 100644 index 0000000000..c298dfbd93 --- /dev/null +++ b/pkg/helmvm/types/types.go @@ -0,0 +1,33 @@ +package types + +type HelmVMNodes struct { + Nodes []Node `json:"nodes"` + HA bool `json:"ha"` + IsHelmVMEnabled bool `json:"isHelmVMEnabled"` +} + +type Node struct { + Name string `json:"name"` + IsConnected bool `json:"isConnected"` + IsReady bool `json:"isReady"` + IsPrimaryNode bool `json:"isPrimaryNode"` + CanDelete bool `json:"canDelete"` + KubeletVersion string `json:"kubeletVersion"` + CPU CapacityAvailable `json:"cpu"` + Memory CapacityAvailable `json:"memory"` + Pods CapacityAvailable `json:"pods"` + Labels []string `json:"labels"` + Conditions NodeConditions `json:"conditions"` +} + +type CapacityAvailable struct { + Capacity float64 `json:"capacity"` + Available float64 `json:"available"` +} + +type NodeConditions struct { + MemoryPressure bool `json:"memoryPressure"` + DiskPressure bool `json:"diskPressure"` + PidPressure bool `json:"pidPressure"` + Ready bool `json:"ready"` +} diff --git a/pkg/helmvm/util.go b/pkg/helmvm/util.go new file mode 100644 index 0000000000..7d2817f93e --- /dev/null +++ b/pkg/helmvm/util.go @@ -0,0 +1,13 @@ +package helmvm + +import ( + "k8s.io/client-go/kubernetes" +) + +func IsHelmVM(clientset kubernetes.Interface) (bool, error) { + return false, nil +} + +func IsHA(clientset kubernetes.Interface) (bool, error) { + return false, nil +} diff --git a/pkg/image/constants.go b/pkg/image/constants.go index 3e4fddac70..50fa0ef0ca 100644 --- a/pkg/image/constants.go +++ b/pkg/image/constants.go @@ -5,8 +5,8 @@ package image // image name. const ( - Minio = "minio/minio:RELEASE.2023-08-23T10-07-06Z" - Mc = "minio/mc:RELEASE.2023-08-18T21-57-55Z" + Minio = "minio/minio:RELEASE.2023-09-23T03-47-50Z" + Mc = "minio/mc:RELEASE.2023-09-22T05-07-46Z" Rqlite = "rqlite/rqlite:7.21.4" Dex = "ghcr.io/dexidp/dex:v2.37.0" Schemahero = "schemahero/schemahero:0.14.0" diff --git a/pkg/k8sutil/kotsadm.go b/pkg/k8sutil/kotsadm.go index bc101e168b..a3b625bf50 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 GetKotsadmID(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..455bb34fa4 --- /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 TestGetKotsadmID(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 := GetKotsadmID(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/kotsadm/metadata.go b/pkg/kotsadm/metadata.go index 0e93bf2c38..9c9b045cb0 100644 --- a/pkg/kotsadm/metadata.go +++ b/pkg/kotsadm/metadata.go @@ -1,6 +1,7 @@ package kotsadm import ( + "github.com/replicatedhq/kots/pkg/helmvm" "github.com/replicatedhq/kots/pkg/kotsadm/types" "github.com/replicatedhq/kots/pkg/kurl" "k8s.io/client-go/kubernetes" @@ -8,9 +9,12 @@ import ( func GetMetadata(clientset kubernetes.Interface) types.Metadata { isKurl, _ := kurl.IsKurl(clientset) + isHelmVM, _ := helmvm.IsHelmVM(clientset) + metadata := types.Metadata{ IsAirgap: IsAirgap(), IsKurl: isKurl, + IsHelmVM: isHelmVM, } return metadata diff --git a/pkg/kotsadm/types/metadata.go b/pkg/kotsadm/types/metadata.go index 9e2586ec15..79e8b142a6 100644 --- a/pkg/kotsadm/types/metadata.go +++ b/pkg/kotsadm/types/metadata.go @@ -3,4 +3,5 @@ package types type Metadata struct { IsAirgap bool IsKurl bool + IsHelmVM bool } diff --git a/pkg/kotsadmupstream/upstream.go b/pkg/kotsadmupstream/upstream.go index 7d2f6eb4f4..1e2e77e5fd 100644 --- a/pkg/kotsadmupstream/upstream.go +++ b/pkg/kotsadmupstream/upstream.go @@ -222,6 +222,7 @@ func DownloadUpdate(appID string, update types.Update, skipPreflights bool, skip ExcludeAdminConsole: true, CreateAppDir: false, ReportWriter: pipeWriter, + AppID: a.ID, AppSlug: a.Slug, AppSequence: appSequence, IsGitOps: a.IsGitOps, diff --git a/pkg/kotsutil/kots.go b/pkg/kotsutil/kots.go index a26490eaf8..440a10ebba 100644 --- a/pkg/kotsutil/kots.go +++ b/pkg/kotsutil/kots.go @@ -75,25 +75,6 @@ type OverlySimpleMetadata struct { Namespace string `yaml:"namespace"` } -// HelmChartInterface represents any kots.io HelmChart (v1beta1 or v1beta2) -type HelmChartInterface interface { - GetAPIVersion() string - GetChartName() string - GetChartVersion() string - GetReleaseName() string - GetDirName() string - GetNamespace() string - GetUpgradeFlags() []string - GetWeight() int64 - GetHelmVersion() string - GetBuilderValues() (map[string]interface{}, error) - SetChartNamespace(namespace string) -} - -// v1beta1 and v1beta2 HelmChart structs must implement HelmChartInterface -var _ HelmChartInterface = (*kotsv1beta1.HelmChart)(nil) -var _ HelmChartInterface = (*kotsv1beta2.HelmChart)(nil) - // KotsKinds are all of the special "client-side" kinds that are packaged in // an application. These should be pointers because they are all optional. // But a few are still expected in the code later, so we make them not pointers, 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/online/online.go b/pkg/online/online.go index a7435fb1a9..2d91e585e4 100644 --- a/pkg/online/online.go +++ b/pkg/online/online.go @@ -151,6 +151,7 @@ func CreateAppFromOnline(opts CreateOnlineAppOpts) (_ *kotsutil.KotsKinds, final ConfigFile: configFile, IdentityConfigFile: identityConfigFile, ReportWriter: pipeWriter, + AppID: opts.PendingApp.ID, AppSlug: opts.PendingApp.Slug, AppSequence: 0, AppVersionLabel: opts.PendingApp.VersionLabel, diff --git a/pkg/operator/client/client.go b/pkg/operator/client/client.go index bc6df7693d..dfa64abdb5 100644 --- a/pkg/operator/client/client.go +++ b/pkg/operator/client/client.go @@ -22,7 +22,6 @@ import ( appstatetypes "github.com/replicatedhq/kots/pkg/appstate/types" "github.com/replicatedhq/kots/pkg/binaries" "github.com/replicatedhq/kots/pkg/k8sutil" - "github.com/replicatedhq/kots/pkg/kotsutil" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/operator/applier" operatortypes "github.com/replicatedhq/kots/pkg/operator/types" @@ -32,6 +31,7 @@ import ( "github.com/replicatedhq/kots/pkg/supportbundle" supportbundletypes "github.com/replicatedhq/kots/pkg/supportbundle/types" "github.com/replicatedhq/kots/pkg/util" + "github.com/replicatedhq/kotskinds/pkg/helmchart" "go.uber.org/zap" ) @@ -57,19 +57,18 @@ type Client struct { watchedNamespaces []string imagePullSecrets []string - appStateMonitor *appstate.Monitor - HookStopChans []chan struct{} - namespaceStopChan chan struct{} - ExistingInformers map[string]bool // namespaces map to invoke the Informer once during deploy + appStateMonitor *appstate.Monitor + HookStopChans []chan struct{} + namespaceStopChan chan struct{} + ExistingHookInformers map[string]bool // namespaces map to invoke the Informer once during deploy } func (c *Client) Init() error { - if _, ok := c.ExistingInformers[c.TargetNamespace]; !ok { - c.ExistingInformers[c.TargetNamespace] = true + if _, ok := c.ExistingHookInformers[c.TargetNamespace]; !ok { + c.ExistingHookInformers[c.TargetNamespace] = true if err := c.runHooksInformer(c.TargetNamespace); err != nil { // we don't fail here... - log.Printf("error registering cleanup hooks for TargetNamespace: %s: %s", - c.TargetNamespace, err.Error()) + log.Printf("error registering cleanup hooks for TargetNamespace: %s: %s", c.TargetNamespace, err.Error()) } } @@ -125,6 +124,45 @@ func (c *Client) runAppStateMonitor() error { return errors.New("app state monitor shutdown") } +func (c *Client) ApplyNamespacesInformer(namespaces []string, imagePullSecrets []string) { + for _, ns := range namespaces { + if ns == "*" { + continue + } + if err := c.ensureNamespacePresent(ns); err != nil { + // we don't fail here... + log.Printf("error creating namespace: %s", err.Error()) + } + if err := c.ensureImagePullSecretsPresent(ns, imagePullSecrets); err != nil { + // we don't fail here... + log.Printf("error ensuring image pull secrets for namespace %s: %s", ns, err.Error()) + } + } + + c.imagePullSecrets = imagePullSecrets + c.watchedNamespaces = namespaces + + c.shutdownNamespacesInformer() + if len(c.watchedNamespaces) > 0 { + c.runNamespacesInformer() + } +} + +func (c *Client) ApplyHooksInformer(namespaces []string) { + for _, ns := range namespaces { + if ns == "*" { + continue + } + if _, ok := c.ExistingHookInformers[ns]; !ok { + c.ExistingHookInformers[ns] = true + if err := c.runHooksInformer(ns); err != nil { + // we don't fail here... + log.Printf("error registering cleanup hooks for namespace: %s: %s", ns, err.Error()) + } + } + } +} + func (c *Client) DeployApp(deployArgs operatortypes.DeployAppArgs) (deployed bool, finalError error) { log.Println("received a deploy request for", deployArgs.AppSlug) @@ -160,11 +198,6 @@ func (c *Client) DeployApp(deployArgs operatortypes.DeployAppArgs) (deployed boo return } - c.shutdownNamespacesInformer() - if len(c.watchedNamespaces) > 0 { - c.runNamespacesInformer() - } - return } @@ -214,30 +247,6 @@ func (c *Client) deployManifests(deployArgs operatortypes.DeployAppArgs) (*deplo } } - for _, additionalNamespace := range deployArgs.AdditionalNamespaces { - if additionalNamespace == "*" { - continue - } - if err := c.ensureNamespacePresent(additionalNamespace); err != nil { - // we don't fail here... - log.Printf("error creating namespace: %s", err.Error()) - } - if err := c.ensureImagePullSecretsPresent(additionalNamespace, deployArgs.ImagePullSecrets); err != nil { - // we don't fail here... - log.Printf("error ensuring image pull secrets for namespace %s: %s", additionalNamespace, err.Error()) - } - if _, ok := c.ExistingInformers[additionalNamespace]; !ok { - c.ExistingInformers[additionalNamespace] = true - if err := c.runHooksInformer(additionalNamespace); err != nil { - // we don't fail here... - log.Printf("error registering cleanup hooks for additionalNamespace: %s: %s", - additionalNamespace, err.Error()) - } - } - } - c.imagePullSecrets = deployArgs.ImagePullSecrets - c.watchedNamespaces = deployArgs.AdditionalNamespaces - result, err := c.ensureResourcesPresent(deployArgs) if err != nil { return nil, errors.Wrap(err, "failed to deploy") @@ -276,7 +285,7 @@ func (c *Client) deployHelmCharts(deployArgs operatortypes.DeployAppArgs) (*comm defer os.RemoveAll(curV1Beta2HelmDir) // find removed charts - prevKotsV1Beta1Charts := []kotsutil.HelmChartInterface{} + prevKotsV1Beta1Charts := []helmchart.HelmChartInterface{} if deployArgs.PreviousKotsKinds != nil && deployArgs.PreviousKotsKinds.V1Beta1HelmCharts != nil { for _, kotsChart := range deployArgs.PreviousKotsKinds.V1Beta1HelmCharts.Items { kc := kotsChart @@ -284,7 +293,7 @@ func (c *Client) deployHelmCharts(deployArgs operatortypes.DeployAppArgs) (*comm } } - curV1Beta1KotsCharts := []kotsutil.HelmChartInterface{} + curV1Beta1KotsCharts := []helmchart.HelmChartInterface{} if deployArgs.KotsKinds != nil && deployArgs.KotsKinds.V1Beta1HelmCharts != nil { for _, kotsChart := range deployArgs.KotsKinds.V1Beta1HelmCharts.Items { kc := kotsChart @@ -292,7 +301,7 @@ func (c *Client) deployHelmCharts(deployArgs operatortypes.DeployAppArgs) (*comm } } - prevKotsV1Beta2Charts := []kotsutil.HelmChartInterface{} + prevKotsV1Beta2Charts := []helmchart.HelmChartInterface{} if deployArgs.PreviousKotsKinds != nil && deployArgs.PreviousKotsKinds.V1Beta2HelmCharts != nil { for _, kotsChart := range deployArgs.PreviousKotsKinds.V1Beta2HelmCharts.Items { kc := kotsChart @@ -300,7 +309,7 @@ func (c *Client) deployHelmCharts(deployArgs operatortypes.DeployAppArgs) (*comm } } - curV1Beta2KotsCharts := []kotsutil.HelmChartInterface{} + curV1Beta2KotsCharts := []helmchart.HelmChartInterface{} if deployArgs.KotsKinds != nil && deployArgs.KotsKinds.V1Beta2HelmCharts != nil { for _, kotsChart := range deployArgs.KotsKinds.V1Beta2HelmCharts.Items { kc := kotsChart @@ -437,7 +446,7 @@ func (c *Client) undeployManifests(undeployArgs operatortypes.UndeployAppArgs) e } func (c *Client) undeployHelmCharts(undeployArgs operatortypes.UndeployAppArgs) error { - kotsCharts := []kotsutil.HelmChartInterface{} + kotsCharts := []helmchart.HelmChartInterface{} if undeployArgs.KotsKinds != nil { if undeployArgs.KotsKinds.V1Beta1HelmCharts != nil { for _, v1Beta1Chart := range undeployArgs.KotsKinds.V1Beta1HelmCharts.Items { diff --git a/pkg/operator/client/client_interface.go b/pkg/operator/client/client_interface.go index 975cc95b01..19af0c3e25 100644 --- a/pkg/operator/client/client_interface.go +++ b/pkg/operator/client/client_interface.go @@ -10,4 +10,6 @@ type ClientInterface interface { DeployApp(deployArgs operatortypes.DeployAppArgs) (deployed bool, finalError error) UndeployApp(undeployArgs operatortypes.UndeployAppArgs) error ApplyAppInformers(args operatortypes.AppInformersArgs) + ApplyNamespacesInformer(namespaces []string, imagePullSecrets []string) + ApplyHooksInformer(namespaces []string) } diff --git a/pkg/operator/client/deploy.go b/pkg/operator/client/deploy.go index 47db19d68a..4d2f881e3a 100644 --- a/pkg/operator/client/deploy.go +++ b/pkg/operator/client/deploy.go @@ -18,11 +18,11 @@ import ( "github.com/replicatedhq/kots/pkg/archives" "github.com/replicatedhq/kots/pkg/helm" "github.com/replicatedhq/kots/pkg/k8sutil" - "github.com/replicatedhq/kots/pkg/kotsutil" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/operator/applier" operatortypes "github.com/replicatedhq/kots/pkg/operator/types" "github.com/replicatedhq/kots/pkg/util" + "github.com/replicatedhq/kotskinds/pkg/helmchart" "github.com/replicatedhq/yaml/v3" corev1 "k8s.io/api/core/v1" kuberneteserrors "k8s.io/apimachinery/pkg/api/errors" @@ -44,6 +44,8 @@ type deployResult struct { } func (c *Client) ensureNamespacePresent(name string) error { + logger.Infof("ensuring namespace %s", name) + clientset, err := k8sutil.GetClientset() if err != nil { return errors.Wrap(err, "failed to get clientset") @@ -71,6 +73,8 @@ func (c *Client) ensureNamespacePresent(name string) error { } func (c *Client) ensureImagePullSecretsPresent(namespace string, imagePullSecrets []string) error { + logger.Infof("ensuring image pull secrets for namespace %s", namespace) + imagePullSecretsMtx.Lock() defer imagePullSecretsMtx.Unlock() @@ -246,7 +250,7 @@ func (c *Client) ensureResourcesPresent(deployArgs operatortypes.DeployAppArgs) return &deployRes, nil } -func (c *Client) installWithHelm(v1Beta1ChartsDir, v1beta2ChartsDir string, kotsCharts []kotsutil.HelmChartInterface) (*commandResult, error) { +func (c *Client) installWithHelm(v1Beta1ChartsDir, v1beta2ChartsDir string, kotsCharts []helmchart.HelmChartInterface) (*commandResult, error) { orderedDirs, err := getSortedCharts(v1Beta1ChartsDir, v1beta2ChartsDir, kotsCharts, c.TargetNamespace, false) if err != nil { return nil, errors.Wrap(err, "failed to get sorted charts") @@ -327,7 +331,7 @@ type orderedDir struct { APIVersion string } -func getSortedCharts(v1Beta1ChartsDir string, v1Beta2ChartsDir string, kotsCharts []kotsutil.HelmChartInterface, targetNamespace string, isUninstall bool) ([]orderedDir, error) { +func getSortedCharts(v1Beta1ChartsDir string, v1Beta2ChartsDir string, kotsCharts []helmchart.HelmChartInterface, targetNamespace string, isUninstall bool) ([]orderedDir, error) { // get a list of the chart directories foundDirs := []orderedDir{} @@ -501,7 +505,7 @@ func findChartNameAndVersionInArchive(archivePath string) (string, string, error return findChartNameAndVersion(tmpDir) } -func (c *Client) uninstallWithHelm(v1Beta1ChartsDir, v1Beta2ChartsDir string, kotsCharts []kotsutil.HelmChartInterface) error { +func (c *Client) uninstallWithHelm(v1Beta1ChartsDir, v1Beta2ChartsDir string, kotsCharts []helmchart.HelmChartInterface) error { orderedDirs, err := getSortedCharts(v1Beta1ChartsDir, v1Beta2ChartsDir, kotsCharts, c.TargetNamespace, true) if err != nil { return errors.Wrap(err, "failed to get sorted charts") @@ -536,17 +540,17 @@ func (c *Client) uninstallWithHelm(v1Beta1ChartsDir, v1Beta2ChartsDir string, ko type getRemovedChartsOptions struct { prevV1Beta1Dir string curV1Beta1Dir string - previousV1Beta1KotsCharts []kotsutil.HelmChartInterface - currentV1Beta1KotsCharts []kotsutil.HelmChartInterface + previousV1Beta1KotsCharts []helmchart.HelmChartInterface + currentV1Beta1KotsCharts []helmchart.HelmChartInterface prevV1Beta2Dir string curV1Beta2Dir string - previousV1Beta2KotsCharts []kotsutil.HelmChartInterface - currentV1Beta2KotsCharts []kotsutil.HelmChartInterface + previousV1Beta2KotsCharts []helmchart.HelmChartInterface + currentV1Beta2KotsCharts []helmchart.HelmChartInterface } // getRemovedCharts returns a list of helm release names that were removed in the current version -func getRemovedCharts(opts getRemovedChartsOptions) ([]kotsutil.HelmChartInterface, error) { - prevCharts := []kotsutil.HelmChartInterface{} +func getRemovedCharts(opts getRemovedChartsOptions) ([]helmchart.HelmChartInterface, error) { + prevCharts := []helmchart.HelmChartInterface{} if opts.prevV1Beta1Dir != "" { prevV1Beta1ChartsDir := filepath.Join(opts.prevV1Beta1Dir, "charts") @@ -566,7 +570,7 @@ func getRemovedCharts(opts getRemovedChartsOptions) ([]kotsutil.HelmChartInterfa prevCharts = append(prevCharts, matching...) } - curCharts := []kotsutil.HelmChartInterface{} + curCharts := []helmchart.HelmChartInterface{} if opts.curV1Beta1Dir != "" { curV1Beta1ChartsDir := filepath.Join(opts.curV1Beta1Dir, "charts") @@ -586,7 +590,7 @@ func getRemovedCharts(opts getRemovedChartsOptions) ([]kotsutil.HelmChartInterfa curCharts = append(curCharts, matching...) } - removedCharts := []kotsutil.HelmChartInterface{} + removedCharts := []helmchart.HelmChartInterface{} for _, prevChart := range prevCharts { found := false for _, curChart := range curCharts { @@ -608,13 +612,13 @@ func getRemovedCharts(opts getRemovedChartsOptions) ([]kotsutil.HelmChartInterfa return removedCharts, nil } -func findMatchingHelmCharts(chartsDir string, kotsCharts []kotsutil.HelmChartInterface) ([]kotsutil.HelmChartInterface, error) { +func findMatchingHelmCharts(chartsDir string, kotsCharts []helmchart.HelmChartInterface) ([]helmchart.HelmChartInterface, error) { dirContent, err := ioutil.ReadDir(chartsDir) if err != nil { return nil, errors.Wrapf(err, "failed to list chart dir %s", chartsDir) } - matching := []kotsutil.HelmChartInterface{} + matching := []helmchart.HelmChartInterface{} for _, kotsChart := range kotsCharts { for _, f := range dirContent { diff --git a/pkg/operator/client/deploy_test.go b/pkg/operator/client/deploy_test.go index 15c2b168a4..98a3535b87 100644 --- a/pkg/operator/client/deploy_test.go +++ b/pkg/operator/client/deploy_test.go @@ -12,9 +12,9 @@ import ( "github.com/mholt/archiver/v3" "github.com/pmezard/go-difflib/difflib" - "github.com/replicatedhq/kots/pkg/kotsutil" "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/replicatedhq/kotskinds/apis/kots/v1beta2" + "github.com/replicatedhq/kotskinds/pkg/helmchart" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -28,7 +28,7 @@ func Test_getSortedCharts(t *testing.T) { name string v1Beta1Files []file v1Beta2Files []file - kotsCharts []kotsutil.HelmChartInterface + kotsCharts []helmchart.HelmChartInterface targetNamespace string isUninstall bool want []orderedDir @@ -79,7 +79,7 @@ version: "v1" `, }, }, - kotsCharts: []kotsutil.HelmChartInterface{ + kotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -214,7 +214,7 @@ version: "v1" `, }, }, - kotsCharts: []kotsutil.HelmChartInterface{ + kotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -332,7 +332,7 @@ version: ver1 `, }, }, - kotsCharts: []kotsutil.HelmChartInterface{ + kotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -401,7 +401,7 @@ version: ver2 `, }, }, - kotsCharts: []kotsutil.HelmChartInterface{ + kotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -472,7 +472,7 @@ version: ver2 `, }, }, - kotsCharts: []kotsutil.HelmChartInterface{ + kotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -526,7 +526,7 @@ version: ver2 `, }, }, - kotsCharts: []kotsutil.HelmChartInterface{ + kotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -599,7 +599,7 @@ version: ver2 `, }, }, - kotsCharts: []kotsutil.HelmChartInterface{ + kotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -668,7 +668,7 @@ version: ver2 contents: "H4sIFAAAAAAA/ykAK2FIUjBjSE02THk5NWIzVjBkUzVpWlM5Nk9WVjZNV2xqYW5keVRRbz1IZWxtAOzSsQoCMQwG4M59ij5B/dvECrf6Du4ZDixcq/TOA99eEF3O0YII+ZZ/yJA/kJJrLjLtjmdpi79LmUx3AJCYnwlgm8CeTWCO6UBEiQxCTBSMQ/8qn27zIs3g613b4/6EXPNpbHO+1MGt0VYp4+BeT2HX9wQePthfd1VKKdXPIwAA//8d5AfYAAgAAA==", }, }, - kotsCharts: []kotsutil.HelmChartInterface{ + kotsCharts: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", @@ -716,7 +716,7 @@ version: ver2 contents: "H4sIFAAAAAAA/ykAK2FIUjBjSE02THk5NWIzVjBkUzVpWlM5Nk9WVjZNV2xqYW5keVRRbz1IZWxtAOzSsQoCMQwG4M59ij5B/dvECrf6Du4ZDixcq/TOA99eEF3O0YII+ZZ/yJA/kJJrLjLtjmdpi79LmUx3AJCYnwlgm8CeTWCO6UBEiQxCTBSMQ/8qn27zIs3g613b4/6EXPNpbHO+1MGt0VYp4+BeT2HX9wQePthfd1VKKdXPIwAA//8d5AfYAAgAAA==", }, }, - kotsCharts: []kotsutil.HelmChartInterface{ + kotsCharts: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", @@ -799,7 +799,7 @@ version: ver2 contents: "H4sIFAAAAAAA/ykAK2FIUjBjSE02THk5NWIzVjBkUzVpWlM5Nk9WVjZNV2xqYW5keVRRbz1IZWxtAOzSsQoCMQwG4M59ij5B/dvECrf6Du4ZDixcq/TOA99eEF3O0YII+ZZ/yJA/kJJrLjLtjmdpi79LmUx3AJCYnwlgm8CeTWCO6UBEiQxCTBSMQ/8qn27zIs3g613b4/6EXPNpbHO+1MGt0VYp4+BeT2HX9wQePthfd1VKKdXPIwAA//8d5AfYAAgAAA==", }, }, - kotsCharts: []kotsutil.HelmChartInterface{ + kotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -916,7 +916,7 @@ version: ver2 contents: "H4sIFAAAAAAA/ykAK2FIUjBjSE02THk5NWIzVjBkUzVpWlM5Nk9WVjZNV2xqYW5keVRRbz1IZWxtAOzSsQoCMQwG4M59ij5B/dvECrf6Du4ZDixcq/TOA99eEF3O0YII+ZZ/yJA/kJJrLjLtjmdpi79LmUx3AJCYnwlgm8CeTWCO6UBEiQxCTBSMQ/8qn27zIs3g613b4/6EXPNpbHO+1MGt0VYp4+BeT2HX9wQePthfd1VKKdXPIwAA//8d5AfYAAgAAA==", }, }, - kotsCharts: []kotsutil.HelmChartInterface{ + kotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -1052,11 +1052,11 @@ func Test_getRemovedCharts(t *testing.T) { curV1Beta1Charts []chart prevV1Beta2Charts []chart curV1Beta2Charts []chart - previousV1Beta1KotsCharts []kotsutil.HelmChartInterface - currentV1Beta1KotsCharts []kotsutil.HelmChartInterface - previousV1Beta2KotsCharts []kotsutil.HelmChartInterface - currentV1Beta2KotsCharts []kotsutil.HelmChartInterface - want []kotsutil.HelmChartInterface + previousV1Beta1KotsCharts []helmchart.HelmChartInterface + currentV1Beta1KotsCharts []helmchart.HelmChartInterface + previousV1Beta2KotsCharts []helmchart.HelmChartInterface + currentV1Beta2KotsCharts []helmchart.HelmChartInterface + want []helmchart.HelmChartInterface }{ // ---- V1BETA1 ---- // { @@ -1085,7 +1085,7 @@ func Test_getRemovedCharts(t *testing.T) { dirName: "chart2-release", }, }, - previousV1Beta1KotsCharts: []kotsutil.HelmChartInterface{ + previousV1Beta1KotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -1119,7 +1119,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - currentV1Beta1KotsCharts: []kotsutil.HelmChartInterface{ + currentV1Beta1KotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -1153,7 +1153,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - want: []kotsutil.HelmChartInterface{}, + want: []helmchart.HelmChartInterface{}, }, { name: "v1beta1 -- chart1 removed", @@ -1176,7 +1176,7 @@ func Test_getRemovedCharts(t *testing.T) { dirName: "chart2-release", }, }, - previousV1Beta1KotsCharts: []kotsutil.HelmChartInterface{ + previousV1Beta1KotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -1210,7 +1210,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - currentV1Beta1KotsCharts: []kotsutil.HelmChartInterface{ + currentV1Beta1KotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -1228,7 +1228,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - want: []kotsutil.HelmChartInterface{ + want: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -1273,7 +1273,7 @@ func Test_getRemovedCharts(t *testing.T) { dirName: "chart2-release", }, }, - previousV1Beta1KotsCharts: []kotsutil.HelmChartInterface{ + previousV1Beta1KotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -1307,7 +1307,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - currentV1Beta1KotsCharts: []kotsutil.HelmChartInterface{ + currentV1Beta1KotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -1341,7 +1341,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - want: []kotsutil.HelmChartInterface{}, + want: []helmchart.HelmChartInterface{}, }, { name: "v1beta1 -- chart2 old release removed because release name changed", @@ -1369,7 +1369,7 @@ func Test_getRemovedCharts(t *testing.T) { dirName: "chart2-new-release", }, }, - previousV1Beta1KotsCharts: []kotsutil.HelmChartInterface{ + previousV1Beta1KotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -1403,7 +1403,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - currentV1Beta1KotsCharts: []kotsutil.HelmChartInterface{ + currentV1Beta1KotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -1437,7 +1437,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - want: []kotsutil.HelmChartInterface{ + want: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -1482,7 +1482,7 @@ func Test_getRemovedCharts(t *testing.T) { dirName: "chart2-release", }, }, - previousV1Beta1KotsCharts: []kotsutil.HelmChartInterface{ + previousV1Beta1KotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -1517,7 +1517,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - currentV1Beta1KotsCharts: []kotsutil.HelmChartInterface{ + currentV1Beta1KotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -1552,7 +1552,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - want: []kotsutil.HelmChartInterface{}, + want: []helmchart.HelmChartInterface{}, }, { name: "v1beta1 -- chart1 old namespace removed because namespace changed", @@ -1580,7 +1580,7 @@ func Test_getRemovedCharts(t *testing.T) { dirName: "chart2-release", }, }, - previousV1Beta1KotsCharts: []kotsutil.HelmChartInterface{ + previousV1Beta1KotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -1615,7 +1615,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - currentV1Beta1KotsCharts: []kotsutil.HelmChartInterface{ + currentV1Beta1KotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -1650,7 +1650,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - want: []kotsutil.HelmChartInterface{ + want: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -1697,7 +1697,7 @@ func Test_getRemovedCharts(t *testing.T) { dirName: "chart2-release", }, }, - previousV1Beta2KotsCharts: []kotsutil.HelmChartInterface{ + previousV1Beta2KotsCharts: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", @@ -1731,7 +1731,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - currentV1Beta2KotsCharts: []kotsutil.HelmChartInterface{ + currentV1Beta2KotsCharts: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", @@ -1765,7 +1765,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - want: []kotsutil.HelmChartInterface{}, + want: []helmchart.HelmChartInterface{}, }, { name: "v1beta2 -- chart1 removed", @@ -1788,7 +1788,7 @@ func Test_getRemovedCharts(t *testing.T) { dirName: "chart2-release", }, }, - previousV1Beta2KotsCharts: []kotsutil.HelmChartInterface{ + previousV1Beta2KotsCharts: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", @@ -1822,7 +1822,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - currentV1Beta2KotsCharts: []kotsutil.HelmChartInterface{ + currentV1Beta2KotsCharts: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", @@ -1840,7 +1840,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - want: []kotsutil.HelmChartInterface{ + want: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", @@ -1885,7 +1885,7 @@ func Test_getRemovedCharts(t *testing.T) { dirName: "chart2-release", }, }, - previousV1Beta2KotsCharts: []kotsutil.HelmChartInterface{ + previousV1Beta2KotsCharts: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", @@ -1919,7 +1919,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - currentV1Beta2KotsCharts: []kotsutil.HelmChartInterface{ + currentV1Beta2KotsCharts: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", @@ -1953,7 +1953,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - want: []kotsutil.HelmChartInterface{}, + want: []helmchart.HelmChartInterface{}, }, { name: "v1beta2 -- chart2 old release removed because release name changed", @@ -1981,7 +1981,7 @@ func Test_getRemovedCharts(t *testing.T) { dirName: "chart2-new-release", }, }, - previousV1Beta2KotsCharts: []kotsutil.HelmChartInterface{ + previousV1Beta2KotsCharts: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", @@ -2015,7 +2015,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - currentV1Beta2KotsCharts: []kotsutil.HelmChartInterface{ + currentV1Beta2KotsCharts: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", @@ -2049,7 +2049,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - want: []kotsutil.HelmChartInterface{ + want: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", @@ -2094,7 +2094,7 @@ func Test_getRemovedCharts(t *testing.T) { dirName: "chart2-release", }, }, - previousV1Beta2KotsCharts: []kotsutil.HelmChartInterface{ + previousV1Beta2KotsCharts: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", @@ -2129,7 +2129,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - currentV1Beta2KotsCharts: []kotsutil.HelmChartInterface{ + currentV1Beta2KotsCharts: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", @@ -2164,7 +2164,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - want: []kotsutil.HelmChartInterface{}, + want: []helmchart.HelmChartInterface{}, }, { name: "v1beta2 -- chart1 old namespace removed because namespace changed", @@ -2192,7 +2192,7 @@ func Test_getRemovedCharts(t *testing.T) { dirName: "chart2-release", }, }, - previousV1Beta2KotsCharts: []kotsutil.HelmChartInterface{ + previousV1Beta2KotsCharts: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", @@ -2227,7 +2227,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - currentV1Beta2KotsCharts: []kotsutil.HelmChartInterface{ + currentV1Beta2KotsCharts: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", @@ -2262,7 +2262,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - want: []kotsutil.HelmChartInterface{ + want: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", @@ -2299,7 +2299,7 @@ func Test_getRemovedCharts(t *testing.T) { dirName: "chart1-release", }, }, - previousV1Beta1KotsCharts: []kotsutil.HelmChartInterface{ + previousV1Beta1KotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -2317,7 +2317,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - currentV1Beta1KotsCharts: []kotsutil.HelmChartInterface{ + currentV1Beta1KotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -2349,7 +2349,7 @@ func Test_getRemovedCharts(t *testing.T) { dirName: "chart2-release", }, }, - previousV1Beta2KotsCharts: []kotsutil.HelmChartInterface{ + previousV1Beta2KotsCharts: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", @@ -2367,7 +2367,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - currentV1Beta2KotsCharts: []kotsutil.HelmChartInterface{ + currentV1Beta2KotsCharts: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", @@ -2385,7 +2385,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - want: []kotsutil.HelmChartInterface{}, + want: []helmchart.HelmChartInterface{}, }, { name: "mix of v1beta1 and v1beta2 -- chart2 removed", @@ -2403,7 +2403,7 @@ func Test_getRemovedCharts(t *testing.T) { dirName: "chart1-release", }, }, - previousV1Beta1KotsCharts: []kotsutil.HelmChartInterface{ + previousV1Beta1KotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -2421,7 +2421,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - currentV1Beta1KotsCharts: []kotsutil.HelmChartInterface{ + currentV1Beta1KotsCharts: []helmchart.HelmChartInterface{ &v1beta1.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta1", @@ -2447,7 +2447,7 @@ func Test_getRemovedCharts(t *testing.T) { }, }, curV1Beta2Charts: []chart{}, - previousV1Beta2KotsCharts: []kotsutil.HelmChartInterface{ + previousV1Beta2KotsCharts: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", @@ -2465,8 +2465,8 @@ func Test_getRemovedCharts(t *testing.T) { }, }, }, - currentV1Beta2KotsCharts: []kotsutil.HelmChartInterface{}, - want: []kotsutil.HelmChartInterface{ + currentV1Beta2KotsCharts: []helmchart.HelmChartInterface{}, + want: []helmchart.HelmChartInterface{ &v1beta2.HelmChart{ TypeMeta: metav1.TypeMeta{ APIVersion: "kots.io/v1beta2", diff --git a/pkg/operator/client/hooks.go b/pkg/operator/client/hooks.go index fd1019a0d7..fa096c6243 100644 --- a/pkg/operator/client/hooks.go +++ b/pkg/operator/client/hooks.go @@ -16,6 +16,8 @@ import ( // runHooksInformer will create goroutines to start various informers for kots objects func (c *Client) runHooksInformer(namespace string) error { + logger.Infof("running hooks informer for namespace %s", namespace) + clientset, err := k8sutil.GetClientset() if err != nil { return errors.Wrap(err, "failed to get clientset") diff --git a/pkg/operator/client/mock/mock.go b/pkg/operator/client/mock/mock.go index ca21376359..1569a58bd1 100644 --- a/pkg/operator/client/mock/mock.go +++ b/pkg/operator/client/mock/mock.go @@ -46,6 +46,30 @@ func (mr *MockClientInterfaceMockRecorder) ApplyAppInformers(args interface{}) * return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplyAppInformers", reflect.TypeOf((*MockClientInterface)(nil).ApplyAppInformers), args) } +// ApplyHooksInformer mocks base method. +func (m *MockClientInterface) ApplyHooksInformer(namespaces []string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "ApplyHooksInformer", namespaces) +} + +// ApplyHooksInformer indicates an expected call of ApplyHooksInformer. +func (mr *MockClientInterfaceMockRecorder) ApplyHooksInformer(namespaces interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplyHooksInformer", reflect.TypeOf((*MockClientInterface)(nil).ApplyHooksInformer), namespaces) +} + +// ApplyNamespacesInformer mocks base method. +func (m *MockClientInterface) ApplyNamespacesInformer(namespaces, imagePullSecrets []string) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "ApplyNamespacesInformer", namespaces, imagePullSecrets) +} + +// ApplyNamespacesInformer indicates an expected call of ApplyNamespacesInformer. +func (mr *MockClientInterfaceMockRecorder) ApplyNamespacesInformer(namespaces, imagePullSecrets interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplyNamespacesInformer", reflect.TypeOf((*MockClientInterface)(nil).ApplyNamespacesInformer), namespaces, imagePullSecrets) +} + // DeployApp mocks base method. func (m *MockClientInterface) DeployApp(deployArgs types.DeployAppArgs) (bool, error) { m.ctrl.T.Helper() diff --git a/pkg/operator/client/namespaces.go b/pkg/operator/client/namespaces.go index b84c22565f..7e6f16394c 100644 --- a/pkg/operator/client/namespaces.go +++ b/pkg/operator/client/namespaces.go @@ -44,6 +44,8 @@ func (c *Client) runNamespacesInformer() error { // we don't fail here... log.Printf("error ensuring image pull secrets for namespace %s: %s", addedNamespace.Name, err.Error()) } + + c.ApplyHooksInformer([]string{addedNamespace.Name}) } }, }) diff --git a/pkg/operator/helm.go b/pkg/operator/helm.go deleted file mode 100644 index 28e686efc1..0000000000 --- a/pkg/operator/helm.go +++ /dev/null @@ -1,44 +0,0 @@ -package operator - -import ( - "io/ioutil" - "os" - "path/filepath" - - "github.com/pkg/errors" - "github.com/replicatedhq/kots/pkg/util" -) - -func getChartsImagePullSecrets(deployedVersionArchive string) ([]string, error) { - archiveChartDir := filepath.Join(deployedVersionArchive, "overlays", "midstream", "charts") - chartDirs, err := ioutil.ReadDir(archiveChartDir) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, errors.Wrap(err, "failed to read charts directory") - } - - imagePullSecrets := []string{} - for _, chartDir := range chartDirs { - if !chartDir.IsDir() { - continue - } - - secretFilename := filepath.Join(archiveChartDir, chartDir.Name(), "secret.yaml") - secretData, err := ioutil.ReadFile(secretFilename) - if err != nil { - if os.IsNotExist(err) { - continue - } - return nil, errors.Wrap(err, "failed to read helm tar.gz file") - } - - secrets := util.ConvertToSingleDocs(secretData) - for _, secret := range secrets { - imagePullSecrets = append(imagePullSecrets, string(secret)) - } - } - - return imagePullSecrets, nil -} diff --git a/pkg/operator/image_pull_secrets.go b/pkg/operator/image_pull_secrets.go new file mode 100644 index 0000000000..2b71d13f40 --- /dev/null +++ b/pkg/operator/image_pull_secrets.go @@ -0,0 +1,72 @@ +package operator + +import ( + "os" + "path/filepath" + + "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/util" +) + +func getImagePullSecrets(deployedVersionArchive string) ([]string, error) { + imagePullSecrets := []string{} + + secretFilename := filepath.Join(deployedVersionArchive, "overlays", "midstream", "secret.yaml") + _, err := os.Stat(secretFilename) + if err != nil && !os.IsNotExist(err) { + return nil, errors.Wrap(err, "failed to os stat image pull secret file") + } + if err == nil { + b, err := os.ReadFile(secretFilename) + if err != nil { + return nil, errors.Wrap(err, "failed to read image pull secret file") + } + secrets := util.ConvertToSingleDocs(b) + for _, secret := range secrets { + imagePullSecrets = append(imagePullSecrets, string(secret)) + } + } + + chartPullSecrets, err := getChartsImagePullSecrets(deployedVersionArchive) + if err != nil { + return nil, errors.Wrap(err, "failed to read image pull secret files from charts") + } + imagePullSecrets = append(imagePullSecrets, chartPullSecrets...) + imagePullSecrets = deduplicateSecrets(imagePullSecrets) + + return imagePullSecrets, nil +} + +func getChartsImagePullSecrets(deployedVersionArchive string) ([]string, error) { + archiveChartDir := filepath.Join(deployedVersionArchive, "overlays", "midstream", "charts") + chartDirs, err := os.ReadDir(archiveChartDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, errors.Wrap(err, "failed to read charts directory") + } + + imagePullSecrets := []string{} + for _, chartDir := range chartDirs { + if !chartDir.IsDir() { + continue + } + + secretFilename := filepath.Join(archiveChartDir, chartDir.Name(), "secret.yaml") + secretData, err := os.ReadFile(secretFilename) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, errors.Wrap(err, "failed to read helm tar.gz file") + } + + secrets := util.ConvertToSingleDocs(secretData) + for _, secret := range secrets { + imagePullSecrets = append(imagePullSecrets, string(secret)) + } + } + + return imagePullSecrets, nil +} diff --git a/pkg/operator/operator.go b/pkg/operator/operator.go index c2217cabc8..eb0b698bc0 100644 --- a/pkg/operator/operator.go +++ b/pkg/operator/operator.go @@ -6,7 +6,6 @@ import ( "encoding/base64" "encoding/json" "fmt" - "io/ioutil" "os" "path/filepath" "strconv" @@ -92,7 +91,7 @@ func (o *Operator) Start() error { } o.clusterID = id - go o.resumeStatusInformers() + go o.resumeInformers() go o.resumeDeployments() startLoop(o.restoreLoop, 2) @@ -216,7 +215,7 @@ func (o *Operator) DeployApp(appID string, sequence int64) (deployed bool, deplo return false, errors.Wrap(err, "failed to get downstream") } - deployedVersionArchive, err := ioutil.TempDir("", "kotsadm") + deployedVersionArchive, err := os.MkdirTemp("", "kotsadm") if err != nil { return false, errors.Wrap(err, "failed to create temp dir") } @@ -324,30 +323,10 @@ func (o *Operator) DeployApp(appID string, sequence int64) (deployed bool, deplo return false, errors.Wrap(err, "failed to get v1beta2 charts archive") } - imagePullSecrets := []string{} - secretFilename := filepath.Join(deployedVersionArchive, "overlays", "midstream", "secret.yaml") - _, err = os.Stat(secretFilename) - if err != nil && !os.IsNotExist(err) { - return false, errors.Wrap(err, "failed to os stat image pull secret file") - } - if err == nil { - b, err := ioutil.ReadFile(secretFilename) - if err != nil { - return false, errors.Wrap(err, "failed to read image pull secret file") - } - secrets := util.ConvertToSingleDocs(b) - for _, secret := range secrets { - imagePullSecrets = append(imagePullSecrets, string(secret)) - } - } - - chartPullSecrets, err := getChartsImagePullSecrets(deployedVersionArchive) + imagePullSecrets, err := getImagePullSecrets(deployedVersionArchive) if err != nil { - deployError = errors.Wrap(err, "failed to read image pull secret files from charts") - return false, deployError + return false, errors.Wrap(err, "failed to get image pull secrets") } - imagePullSecrets = append(imagePullSecrets, chartPullSecrets...) - imagePullSecrets = deduplicateSecrets(imagePullSecrets) // get previous manifests (if any) var previousKotsKinds *kotsutil.KotsKinds @@ -365,7 +344,7 @@ func (o *Operator) DeployApp(appID string, sequence int64) (deployed bool, deplo } if previouslyDeployedParentSequence != -1 { - previouslyDeployedVersionArchive, err := ioutil.TempDir("", "kotsadm") + previouslyDeployedVersionArchive, err := os.MkdirTemp("", "kotsadm") if err != nil { return false, errors.Wrap(err, "failed to create temp dir") } @@ -403,6 +382,9 @@ func (o *Operator) DeployApp(appID string, sequence int64) (deployed bool, deplo return false, errors.Wrap(err, "failed to apply status informers") } + o.client.ApplyNamespacesInformer(kotsKinds.KotsApplication.Spec.AdditionalNamespaces, imagePullSecrets) + o.client.ApplyHooksInformer(kotsKinds.KotsApplication.Spec.AdditionalNamespaces) + deployArgs := operatortypes.DeployAppArgs{ AppID: app.ID, AppSlug: app.Slug, @@ -489,20 +471,20 @@ func (o *Operator) applyStatusInformers(a *apptypes.App, sequence int64, kotsKin return nil } -func (o *Operator) resumeStatusInformers() { +func (o *Operator) resumeInformers() { apps, err := o.store.ListAppsForDownstream(o.clusterID) if err != nil { logger.Error(errors.Wrap(err, "failed to list installed apps for downstream")) return } for _, app := range apps { - if err := o.resumeStatusInformersForApp(app); err != nil { + if err := o.resumeInformersForApp(app); err != nil { logger.Error(errors.Wrapf(err, "failed to resume status informers for app %s in cluster %s", app.ID, o.clusterID)) } } } -func (o *Operator) resumeStatusInformersForApp(app *apptypes.App) error { +func (o *Operator) resumeInformersForApp(app *apptypes.App) error { deployedVersion, err := o.store.GetCurrentDownstreamVersion(app.ID, o.clusterID) if err != nil { return errors.Wrap(err, "failed to get current downstream version") @@ -513,7 +495,7 @@ func (o *Operator) resumeStatusInformersForApp(app *apptypes.App) error { logger.Debugf("starting status informers for app %s", app.ID) - deployedVersionArchive, err := ioutil.TempDir("", "kotsadm") + deployedVersionArchive, err := os.MkdirTemp("", "kotsadm") if err != nil { return errors.Wrap(err, "failed to create temp dir") } @@ -524,6 +506,11 @@ func (o *Operator) resumeStatusInformersForApp(app *apptypes.App) error { return errors.Wrap(err, "failed to get app version archive") } + imagePullSecrets, err := getImagePullSecrets(deployedVersionArchive) + if err != nil { + return errors.Wrap(err, "failed to get image pull secrets") + } + kotsKinds, err := kotsutil.LoadKotsKindsFromPath(filepath.Join(deployedVersionArchive, "upstream")) if err != nil { return errors.Wrap(err, "failed to load kotskinds") @@ -543,6 +530,9 @@ func (o *Operator) resumeStatusInformersForApp(app *apptypes.App) error { return errors.Wrapf(err, "failed to apply status informers for app %s", app.ID) } + o.client.ApplyNamespacesInformer(kotsKinds.KotsApplication.Spec.AdditionalNamespaces, imagePullSecrets) + o.client.ApplyHooksInformer(kotsKinds.KotsApplication.Spec.AdditionalNamespaces) + return nil } @@ -727,7 +717,7 @@ func (o *Operator) UndeployApp(a *apptypes.App, d *downstreamtypes.Downstream, i return nil } - deployedVersionArchive, err := ioutil.TempDir("", "kotsadm") + deployedVersionArchive, err := os.MkdirTemp("", "kotsadm") if err != nil { return errors.Wrap(err, "failed to create temp dir") } diff --git a/pkg/operator/operator_test.go b/pkg/operator/operator_test.go index e9981f4590..f3ebc02006 100644 --- a/pkg/operator/operator_test.go +++ b/pkg/operator/operator_test.go @@ -63,7 +63,7 @@ spec: Expect(err).ToNot(HaveOccurred()) }) - It("starts the status informers", func() { + It("resumes informers on (re)start", func() { mockClient.EXPECT().Init().Return(nil) mockStore.EXPECT().GetClusterIDFromDeployToken(clusterToken).Return("", nil) @@ -106,6 +106,16 @@ spec: wg.Done() }) + wg.Add(1) + mockClient.EXPECT().ApplyNamespacesInformer(gomock.Any(), gomock.Any()).Times(1).Do(func(namespaces []string, imagePullSecrets []string) { + wg.Done() + }) + + wg.Add(1) + mockClient.EXPECT().ApplyHooksInformer(gomock.Any()).Times(1).Do(func(namespaces []string) { + wg.Done() + }) + err := testOperator.Start() Expect(err).ToNot(HaveOccurred()) @@ -169,6 +179,16 @@ spec: wg.Done() }) + wg.Add(1) + mockClient.EXPECT().ApplyNamespacesInformer(gomock.Any(), gomock.Any()).Times(0).Do(func(namespaces []string, imagePullSecrets []string) { + wg.Done() + }) + + wg.Add(1) + mockClient.EXPECT().ApplyHooksInformer(gomock.Any()).Times(0).Do(func(namespaces []string) { + wg.Done() + }) + err := testOperator.Start() Expect(err).ToNot(HaveOccurred()) @@ -299,9 +319,13 @@ spec: mockStore.EXPECT().GetPreviouslyDeployedSequence(appID, "").Return(previouslyDeployedSequence, nil) - mockClient.EXPECT().DeployApp(gomock.Any()).Return(true, nil) + mockClient.EXPECT().ApplyAppInformers(gomock.Any()).Times(1) + + mockClient.EXPECT().ApplyNamespacesInformer(gomock.Any(), gomock.Any()).Times(1) - mockClient.EXPECT().ApplyAppInformers(gomock.Any()) + mockClient.EXPECT().ApplyHooksInformer(gomock.Any()).Times(1) + + mockClient.EXPECT().DeployApp(gomock.Any()).Return(true, nil) deployed, err := testOperator.DeployApp(appID, sequence) Expect(err).ToNot(HaveOccurred()) @@ -390,13 +414,17 @@ spec: mockStore.EXPECT().GetParentSequenceForSequence(appID, "", previouslyDeployedSequence).Return(int64(0), nil) + mockClient.EXPECT().ApplyAppInformers(gomock.Any()).Times(1) + + mockClient.EXPECT().ApplyNamespacesInformer(gomock.Any(), gomock.Any()).Times(1) + + mockClient.EXPECT().ApplyHooksInformer(gomock.Any()).Times(1) + mockClient.EXPECT().DeployApp(gomock.Any()).DoAndReturn(func(deployArgs operatortypes.DeployAppArgs) (bool, error) { Expect(deployArgs.PreviousManifests).To(BeEmpty()) return true, nil }) - mockClient.EXPECT().ApplyAppInformers(gomock.Any()) - deployed, err := testOperator.DeployApp(appID, sequence) Expect(err).ToNot(HaveOccurred()) Expect(deployed).To(BeTrue()) @@ -596,6 +624,12 @@ spec: mockStore.EXPECT().GetPreviouslyDeployedSequence(appID, "").Return(previouslyDeployedSequence, nil) + mockClient.EXPECT().ApplyAppInformers(gomock.Any()).Times(1) + + mockClient.EXPECT().ApplyNamespacesInformer(gomock.Any(), gomock.Any()).Times(1) + + mockClient.EXPECT().ApplyHooksInformer(gomock.Any()).Times(1) + mockClient.EXPECT().DeployApp(gomock.Any()).Do(func(deployArgs operatortypes.DeployAppArgs) (bool, error) { // validate that the namespace and helm upgrade flags are templated when deploying Expect(deployArgs.KotsKinds.V1Beta1HelmCharts.Items[0].Spec.Namespace).To(Equal(expectedNamespace)) @@ -605,8 +639,6 @@ spec: return true, nil }) - mockClient.EXPECT().ApplyAppInformers(gomock.Any()) - _, err := testOperator.DeployApp(appID, sequence) Expect(err).ToNot(HaveOccurred()) }) diff --git a/pkg/pull/pull.go b/pkg/pull/pull.go index c9df6ef167..24ef5f4436 100644 --- a/pkg/pull/pull.go +++ b/pkg/pull/pull.go @@ -58,6 +58,7 @@ type PullOptions struct { RewriteImageOptions registrytypes.RegistrySettings SkipHelmChartCheck bool ReportWriter io.Writer + AppID string AppSlug string AppSequence int64 AppVersionLabel string @@ -286,6 +287,9 @@ func Pull(upstreamURI string, pullOptions PullOptions) (string, error) { IsOpenShift: k8sutil.IsOpenShift(clientset), IsGKEAutopilot: k8sutil.IsGKEAutopilot(clientset), IncludeMinio: pullOptions.IncludeMinio, + IsAirgap: pullOptions.AirgapRoot != "", + KotsadmID: k8sutil.GetKotsadmID(clientset), + AppID: pullOptions.AppID, } if err := upstream.WriteUpstream(u, writeUpstreamOptions); err != nil { log.FinishSpinnerWithError() diff --git a/pkg/reporting/app.go b/pkg/reporting/app.go index a7942bbdb7..dcb02e12c4 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.GetKotsadmID(clientset) di, err := getDownstreamInfo(appID) if err != nil { diff --git a/pkg/rewrite/rewrite.go b/pkg/rewrite/rewrite.go index cca1f3e5a5..3b1ba5e4ec 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, @@ -103,6 +104,9 @@ func Rewrite(rewriteOptions RewriteOptions) error { PreserveInstallation: true, IsOpenShift: k8sutil.IsOpenShift(clientset), IsGKEAutopilot: k8sutil.IsGKEAutopilot(clientset), + IsAirgap: rewriteOptions.IsAirgap, + KotsadmID: k8sutil.GetKotsadmID(clientset), + AppID: rewriteOptions.AppID, } if err = upstream.WriteUpstream(u, writeUpstreamOptions); err != nil { log.FinishSpinnerWithError() diff --git a/pkg/upstream/fetch.go b/pkg/upstream/fetch.go index 3f325d4fe0..b7663280f8 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,10 @@ func pickCursor(fetchOptions *types.FetchOptions) replicatedapp.ReplicatedCursor Cursor: fetchOptions.CurrentCursor, } } + +func pickReplicatedChartNames(fetchOptions *types.FetchOptions) []string { + if fetchOptions.Airgap != nil { + return fetchOptions.Airgap.Spec.ReplicatedChartNames + } + return fetchOptions.CurrentReplicatedChartNames +} diff --git a/pkg/upstream/helm.go b/pkg/upstream/helm.go new file mode 100644 index 0000000000..287a723aff --- /dev/null +++ b/pkg/upstream/helm.go @@ -0,0 +1,461 @@ +package upstream + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/buildversion" + "github.com/replicatedhq/kots/pkg/kotsutil" + "github.com/replicatedhq/kots/pkg/upstream/types" + "gopkg.in/yaml.v3" +) + +// configureChart will configure the chart archive (values.yaml), +// repackage it, and return the updated content of the chart +func configureChart(chartContent []byte, u *types.Upstream, options types.WriteOptions) ([]byte, error) { + replicatedChartName, isSubchart, err := findReplicatedChart(bytes.NewReader(chartContent), u.ReplicatedChartNames) + if err != nil { + return nil, errors.Wrap(err, "find replicated chart") + } + if replicatedChartName == "" { + return chartContent, nil + } + + chartValues, pathInArchive, extractedArchiveRoot, err := findTopLevelChartValues(bytes.NewReader(chartContent)) + if err != nil { + return nil, errors.Wrap(err, "find top level chart values") + } + defer os.RemoveAll(extractedArchiveRoot) + + updatedValues, err := configureChartValues(chartValues, replicatedChartName, isSubchart, u, options) + if err != nil { + return nil, errors.Wrap(err, "configure values yaml") + } + + if err := os.WriteFile(filepath.Join(extractedArchiveRoot, pathInArchive), updatedValues, 0644); err != nil { + return nil, errors.Wrap(err, "write configured values.yaml") + } + + updatedArchive, err := packageChartArchive(extractedArchiveRoot) + if err != nil { + return nil, errors.Wrap(err, "package chart archive") + } + defer os.RemoveAll(updatedArchive) + + updatedContents, err := os.ReadFile(updatedArchive) + if err != nil { + return nil, errors.Wrap(err, "read updated archive") + } + + return updatedContents, nil +} + +// findReplicatedChart will look for the replicated chart in the archive +// and return the name of the replicated chart and whether it is the parent chart or a subchart +func findReplicatedChart(chartArchive io.Reader, replicatedChartNames []string) (string, bool, error) { + gzReader, err := gzip.NewReader(chartArchive) + 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" { + continue + } + + // we only care about the root Chart.yaml file or the Chart.yaml file of direct subcharts (not subsubcharts) + parts := strings.Split(header.Name, string(os.PathSeparator)) // e.g. replicated/Chart.yaml or nginx/charts/replicated/Chart.yaml + if len(parts) != 2 && len(parts) != 4 { + continue + } + + 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) + } + + for _, replicatedChartName := range replicatedChartNames { + if chartInfo.ChartName == replicatedChartName { + return replicatedChartName, len(parts) == 4, nil + } + } + } + } + + return "", false, 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 +} + +func configureChartValues(valuesYAML []byte, replicatedChartName string, isSubchart bool, u *types.Upstream, options types.WriteOptions) ([]byte, error) { + // unmarshal to insert the replicated values + var valuesNode yaml.Node + if err := yaml.Unmarshal([]byte(valuesYAML), &valuesNode); err != nil { + return nil, errors.Wrap(err, "unmarshal values") + } + + if len(valuesNode.Content) == 0 { + return nil, errors.New("no content") + } + + if replicatedChartName != "" { + err := addReplicatedValues(valuesNode.Content[0], replicatedChartName, isSubchart, u, options) + if err != nil { + return nil, errors.Wrap(err, "add replicated values") + } + } + + if err := addGlobalReplicatedValues(valuesNode.Content[0], u, options); err != nil { + return nil, errors.Wrap(err, "add global replicated values") + } + + updatedValues, err := kotsutil.NodeToYAML(&valuesNode) + if err != nil { + return nil, errors.Wrap(err, "node to yaml") + } + + return updatedValues, nil +} + +func addReplicatedValues(doc *yaml.Node, replicatedChartName string, isSubchart bool, u *types.Upstream, options types.WriteOptions) error { + replicatedValues, err := buildReplicatedValues(u, options) + if err != nil { + return errors.Wrap(err, "build replicated values") + } + + targetNode := doc + hasReplicatedValues := false + v := replicatedValues + + // if replicated sdk is included as a subchart, + // we make sure to add the values under the subchart name + // as helm expects the field name to match the subchart name + if isSubchart { + for i, n := range doc.Content { + if n.Value == replicatedChartName { // check if field already exists + targetNode = doc.Content[i+1] + hasReplicatedValues = true + break + } + } + if !hasReplicatedValues { + v = map[string]interface{}{ + replicatedChartName: 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 && isSubchart { + targetNode.Content = append(targetNode.Content, additionalNode.Content[0].Content...) + } else { + targetNode.Content = kotsutil.MergeYAMLNodes(targetNode.Content, additionalNode.Content[0].Content) + } + + return nil +} + +func buildReplicatedValues(u *types.Upstream, options types.WriteOptions) (map[string]interface{}, error) { + replicatedValues := map[string]interface{}{ + "replicatedID": options.KotsadmID, + "appID": options.AppID, + "userAgent": buildversion.GetUserAgent(), + "isAirgap": options.IsAirgap, + } + + // only add the license if this is an airgap install + // because the airgap builder doesn't have the license context + if u.License != nil && options.IsAirgap { + replicatedValues["license"] = string(MustMarshalLicense(u.License)) + } + + return replicatedValues, nil +} + +func addGlobalReplicatedValues(doc *yaml.Node, u *types.Upstream, options types.WriteOptions) error { + globalReplicatedValues, err := buildGlobalReplicatedValues(u, options) + if err != nil { + return errors.Wrap(err, "build global replicated values") + } + if len(globalReplicatedValues) == 0 { + return nil + } + + 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 + } + } + } + + v := globalReplicatedValues + 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 buildGlobalReplicatedValues(u *types.Upstream, options types.WriteOptions) (map[string]interface{}, error) { + globalReplicatedValues := map[string]interface{}{} + + // only add license related info if this is an airgap install + // because the airgap builder doesn't have the license context + if u.License != nil && options.IsAirgap { + globalReplicatedValues["channelName"] = u.License.Spec.ChannelName + globalReplicatedValues["customerName"] = u.License.Spec.CustomerName + globalReplicatedValues["customerEmail"] = u.License.Spec.CustomerEmail + globalReplicatedValues["licenseID"] = u.License.Spec.LicenseID + globalReplicatedValues["licenseType"] = u.License.Spec.LicenseType + + // we marshal and then unmarshal entitlements into an interface to evaluate entitlement values + // and end up with a single value instead of (intVal, boolVal, strVal, and type) + marshalledEntitlements, err := json.Marshal(u.License.Spec.Entitlements) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal entitlements") + } + + var licenseFields map[string]interface{} + if err := json.Unmarshal(marshalledEntitlements, &licenseFields); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal entitlements") + } + + // add the field name if missing + for k, v := range licenseFields { + if name, ok := v.(map[string]interface{})["name"]; !ok || name == "" { + licenseFields[k].(map[string]interface{})["name"] = k + } + } + + globalReplicatedValues["licenseFields"] = licenseFields + + // add docker config json + 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") + } + + globalReplicatedValues["dockerconfigjson"] = base64.StdEncoding.EncodeToString(b) + } + + return globalReplicatedValues, nil +} + +func packageChartArchive(extractedArchiveRoot string) (string, error) { + configuredChartArchive, err := os.CreateTemp("", "configured-chart-") + if err != nil { + return "", errors.Wrap(err, "create temp file") + } + + gzipWriter := gzip.NewWriter(configuredChartArchive) + defer gzipWriter.Close() + + tarWriter := tar.NewWriter(gzipWriter) + defer tarWriter.Close() + + err = filepath.Walk(extractedArchiveRoot, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + file, err := os.Open(path) + if err != nil { + return errors.Wrapf(err, "open file '%s'", path) + } + defer file.Close() + + rel, err := filepath.Rel(extractedArchiveRoot, path) + if err != nil { + return errors.New(fmt.Sprintf("Could not get relative path for file '%s', got error '%s'", path, err.Error())) + } + + header := &tar.Header{ + Name: rel, + Size: info.Size(), + Mode: int64(info.Mode()), + ModTime: info.ModTime(), + } + + err = tarWriter.WriteHeader(header) + if err != nil { + return errors.New(fmt.Sprintf("Could not write header for file '%s', got error '%s'", path, 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'", path, err.Error())) + } + + return nil + }) + if err != nil { + return "", errors.Wrap(err, "walk file tree") + } + + return configuredChartArchive.Name(), nil +} diff --git a/pkg/upstream/helm_test.go b/pkg/upstream/helm_test.go new file mode 100644 index 0000000000..a921624e7a --- /dev/null +++ b/pkg/upstream/helm_test.go @@ -0,0 +1,1287 @@ +package upstream + +import ( + "fmt" + "testing" + + "github.com/pmezard/go-difflib/difflib" + "github.com/replicatedhq/kots/pkg/upstream/types" + "github.com/replicatedhq/kots/pkg/util" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func Test_configureChart(t *testing.T) { + testReplicatedChartNames := []string{ + "replicated", + "replicated-sdk", + } + + type Test struct { + name string + isAirgap bool + chartContent map[string]string + want map[string]string + wantErr bool + } + + tests := []Test{ + { + name: "online - a standalone non-replicated chart", + isAirgap: false, + chartContent: map[string]string{ + "non-replicated/Chart.yaml": `apiVersion: v1 +name: not-replicated +version: 1.0.0 +description: Not a Replicated Chart +`, + "non-replicated/values.yaml": `# this values.yaml file should not change + +# do not change global values +global: + some: value + +# use this value to configure the chart +some: value +`, + }, + want: map[string]string{ + "non-replicated/Chart.yaml": `apiVersion: v1 +name: not-replicated +version: 1.0.0 +description: Not a Replicated Chart +`, + "non-replicated/values.yaml": `# this values.yaml file should not change + +# do not change global values +global: + some: value + +# use this value to configure the chart +some: value +`, + }, + wantErr: false, + }, + { + name: "airgap - a standalone non-replicated chart", + isAirgap: true, + chartContent: map[string]string{ + "non-replicated/Chart.yaml": `apiVersion: v1 +name: not-replicated +version: 1.0.0 +description: Not a Replicated Chart +`, + "non-replicated/values.yaml": `# this values.yaml file should not change + +# do not change global values +global: + some: value + +# use this value to configure the chart +some: value +`, + }, + want: map[string]string{ + "non-replicated/Chart.yaml": `apiVersion: v1 +name: not-replicated +version: 1.0.0 +description: Not a Replicated Chart +`, + "non-replicated/values.yaml": `# this values.yaml file should not change + +# do not change global values +global: + some: value + +# use this value to configure the chart +some: value +`, + }, + wantErr: false, + }, + { + name: "online - an nginx chart with the 'common' subchart only", + isAirgap: false, + chartContent: map[string]string{ + "nginx/Chart.yaml": `apiVersion: v2 +name: nginx +version: 12.0.1 +description: An NGINX Chart +`, + "nginx/values.yaml": `## @section Global parameters +## Global Docker image parameters +## Please, note that this will override the image parameters, including dependencies, configured to use the global value +## Current available global Docker image parameters: imageRegistry, imagePullSecrets and storageClass + +## @param global.imageRegistry Global Docker image registry +## @param global.imagePullSecrets Global Docker registry secret names as an array +## +global: + imageRegistry: "" + ## E.g. + ## imagePullSecrets: + ## - myRegistryKeySecretName + ## + imagePullSecrets: [] + +## @section Common parameters + +## @param nameOverride String to partially override nginx.fullname template (will maintain the release name) +## +nameOverride: "" +`, + "nginx/charts/common/Chart.yaml": `apiVersion: v2 +name: common +version: 1.13.1 +description: A Common Chart +`, + "nginx/charts/common/values.yaml": `# do not change this file + +# do not change global values +global: + some: value + +# keep this comment +another: value +`, + }, + want: map[string]string{ + "nginx/Chart.yaml": `apiVersion: v2 +name: nginx +version: 12.0.1 +description: An NGINX Chart +`, + "nginx/values.yaml": `## @section Global parameters +## Global Docker image parameters +## Please, note that this will override the image parameters, including dependencies, configured to use the global value +## Current available global Docker image parameters: imageRegistry, imagePullSecrets and storageClass + +## @param global.imageRegistry Global Docker image registry +## @param global.imagePullSecrets Global Docker registry secret names as an array +## +global: + imageRegistry: "" + ## E.g. + ## imagePullSecrets: + ## - myRegistryKeySecretName + ## + imagePullSecrets: [] + +## @section Common parameters + +## @param nameOverride String to partially override nginx.fullname template (will maintain the release name) +## +nameOverride: "" +`, + "nginx/charts/common/Chart.yaml": `apiVersion: v2 +name: common +version: 1.13.1 +description: A Common Chart +`, + "nginx/charts/common/values.yaml": `# do not change this file + +# do not change global values +global: + some: value + +# keep this comment +another: value +`, + }, + wantErr: false, + }, + { + name: "airgap - an nginx chart with the 'common' subchart only", + isAirgap: true, + chartContent: map[string]string{ + "nginx/Chart.yaml": `apiVersion: v2 +name: nginx +version: 12.0.1 +description: An NGINX Chart +`, + "nginx/values.yaml": `## @section Global parameters +## Global Docker image parameters +## Please, note that this will override the image parameters, including dependencies, configured to use the global value +## Current available global Docker image parameters: imageRegistry, imagePullSecrets and storageClass + +## @param global.imageRegistry Global Docker image registry +## @param global.imagePullSecrets Global Docker registry secret names as an array +## +global: + imageRegistry: "" + ## E.g. + ## imagePullSecrets: + ## - myRegistryKeySecretName + ## + imagePullSecrets: [] + +## @section Common parameters + +## @param nameOverride String to partially override nginx.fullname template (will maintain the release name) +## +nameOverride: "" +`, + "nginx/charts/common/Chart.yaml": `apiVersion: v2 +name: common +version: 1.13.1 +description: A Common Chart +`, + "nginx/charts/common/values.yaml": `# do not change this file + +# do not change global values +global: + some: value + +# keep this comment +another: value +`, + }, + want: map[string]string{ + "nginx/Chart.yaml": `apiVersion: v2 +name: nginx +version: 12.0.1 +description: An NGINX Chart +`, + "nginx/values.yaml": `## @section Global parameters +## Global Docker image parameters +## Please, note that this will override the image parameters, including dependencies, configured to use the global value +## Current available global Docker image parameters: imageRegistry, imagePullSecrets and storageClass + +## @param global.imageRegistry Global Docker image registry +## @param global.imagePullSecrets Global Docker registry secret names as an array +## +global: + imageRegistry: "" + ## E.g. + ## imagePullSecrets: + ## - myRegistryKeySecretName + ## + imagePullSecrets: [] + +## @section Common parameters + +## @param nameOverride String to partially override nginx.fullname template (will maintain the release name) +## +nameOverride: "" +`, + "nginx/charts/common/Chart.yaml": `apiVersion: v2 +name: common +version: 1.13.1 +description: A Common Chart +`, + "nginx/charts/common/values.yaml": `# do not change this file + +# do not change global values +global: + some: value + +# keep this comment +another: value +`, + }, + wantErr: false, + }, + } + + // Generate dynamic tests using the supported replicated chart names + for _, chartName := range testReplicatedChartNames { + tests = append(tests, Test{ + name: "online - a standalone replicated chart", + isAirgap: false, + chartContent: map[string]string{ + "replicated/Chart.yaml": fmt.Sprintf(`apiVersion: v1 +name: %s +version: 1.0.0 +description: A Replicated Chart +`, chartName), + "replicated/values.yaml": `# preserve this comment + +license: online-license +appName: online-app-name +channelID: online-channel-id +channelName: online-channel-name +channelSequence: 2 +releaseCreatedAt: "2023-10-02T00:00:00Z" +releaseNotes: override my release notes +releaseSequence: 1 +statusInformers: + - deployment/replicated + - service/replicated +versionLabel: 1.0.0 +# and this comment + +global: + replicated: + licenseID: online-license-id + channelName: online-channel-name + customerName: Online Customer Name + customerEmail: online-customer@example.com + licenseType: dev + dockerconfigjson: bm90LWEtZG9ja2VyLWNvbmZpZy1qc29uCg== + licenseFields: + expires_at: + name: expires_at + title: Expiration + description: License Expiration + value: "" + valueType: String + signature: + v1: nwZmD/sMFzKKxkd7JaAcKU/2uBE5m23w7+8xqLMXjUturMVCF5cF66EVMAibb2nHOqytie+N35GYSwIeTd16PKwbFBDd12c2E5M9COWwjVRcVTz4OnNWmHv9PEqZIbXhvfCLlyJ/aY3zV9Pno1VLFcYxGMrBugncEo4ecHkEbaVp3VLS4wn8EykAC1byvYBshzEXppYYd3c6a9cNw50Z6inI/IaKVxIForuz+Yn5uRAsjRyCY2auBCMeHMhY+CQ+4Vl5WtGjuJuE1g7t8AVZqt2JDBgDuxZAZX/JGncfzUaaDl87athMTtBKnFkTnCl34UXPkhsgM0LC4YoUiyKYjQ== +some: value +# and this comment as well +`, + }, + want: map[string]string{ + "replicated/Chart.yaml": fmt.Sprintf(`apiVersion: v1 +name: %s +version: 1.0.0 +description: A Replicated Chart +`, chartName), + "replicated/values.yaml": `# preserve this comment + +license: online-license +appName: online-app-name +channelID: online-channel-id +channelName: online-channel-name +channelSequence: 2 +releaseCreatedAt: "2023-10-02T00:00:00Z" +releaseNotes: override my release notes +releaseSequence: 1 +statusInformers: + - deployment/replicated + - service/replicated +versionLabel: 1.0.0 +# and this comment + +global: + replicated: + licenseID: online-license-id + channelName: online-channel-name + customerName: Online Customer Name + customerEmail: online-customer@example.com + licenseType: dev + dockerconfigjson: bm90LWEtZG9ja2VyLWNvbmZpZy1qc29uCg== + licenseFields: + expires_at: + name: expires_at + title: Expiration + description: License Expiration + value: "" + valueType: String + signature: + v1: nwZmD/sMFzKKxkd7JaAcKU/2uBE5m23w7+8xqLMXjUturMVCF5cF66EVMAibb2nHOqytie+N35GYSwIeTd16PKwbFBDd12c2E5M9COWwjVRcVTz4OnNWmHv9PEqZIbXhvfCLlyJ/aY3zV9Pno1VLFcYxGMrBugncEo4ecHkEbaVp3VLS4wn8EykAC1byvYBshzEXppYYd3c6a9cNw50Z6inI/IaKVxIForuz+Yn5uRAsjRyCY2auBCMeHMhY+CQ+4Vl5WtGjuJuE1g7t8AVZqt2JDBgDuxZAZX/JGncfzUaaDl87athMTtBKnFkTnCl34UXPkhsgM0LC4YoUiyKYjQ== +some: value +# and this comment as well + +appID: app-id +isAirgap: false +replicatedID: kotsadm-id +userAgent: KOTS/v0.0.0-unknown +`, + }, + wantErr: false, + }) + + tests = append(tests, Test{ + name: "airgap - a standalone replicated chart", + isAirgap: true, + chartContent: map[string]string{ + "replicated/Chart.yaml": fmt.Sprintf(`apiVersion: v1 +name: %s +version: 1.0.0 +description: A Replicated Chart +`, chartName), + "replicated/values.yaml": `# preserve this comment + +license: "" +appName: app-name +channelID: channel-id +channelName: channel-name +channelSequence: 2 +releaseCreatedAt: "2023-10-02T00:00:00Z" +releaseNotes: override my release notes +releaseSequence: 1 +statusInformers: + - deployment/replicated + - service/replicated +versionLabel: 1.0.0 +# and this comment + +some: value +# and this comment as well +`, + }, + want: map[string]string{ + "replicated/Chart.yaml": fmt.Sprintf(`apiVersion: v1 +name: %s +version: 1.0.0 +description: A Replicated Chart +`, chartName), + "replicated/values.yaml": `# preserve this comment + +license: | + apiVersion: kots.io/v1beta1 + kind: License + metadata: + creationTimestamp: null + name: kots-license + 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: dev + signature: "" + status: {} +appName: app-name +channelID: channel-id +channelName: channel-name +channelSequence: 2 +releaseCreatedAt: "2023-10-02T00:00:00Z" +releaseNotes: override my release notes +releaseSequence: 1 +statusInformers: + - deployment/replicated + - service/replicated +versionLabel: 1.0.0 +# and this comment + +some: value +# and this comment as well + +appID: app-id +isAirgap: true +replicatedID: kotsadm-id +userAgent: KOTS/v0.0.0-unknown +global: + replicated: + channelName: channel-name + customerEmail: customer@example.com + customerName: Customer Name + dockerconfigjson: eyJhdXRocyI6eyJjdXN0b20ucHJveHkuY29tIjp7ImF1dGgiOiJiR2xqWlc1elpTMXBaRHBzYVdObGJuTmxMV2xrIn0sImN1c3RvbS5yZWdpc3RyeS5jb20iOnsiYXV0aCI6ImJHbGpaVzV6WlMxcFpEcHNhV05sYm5ObExXbGsifX19 + licenseFields: + license-field: + description: This is a license field + name: license-field + title: License Field + value: license-field-value + valueType: string + licenseID: license-id + licenseType: dev +`, + }, + wantErr: false, + }) + + tests = append(tests, Test{ + name: "online - a guestbook chart with the replicated subchart", + isAirgap: false, + chartContent: map[string]string{ + "guestbook/Chart.yaml": `apiVersion: v2 +name: guestbook +version: 1.16.0 +description: A Guestbook Chart +`, + "guestbook/values.yaml": fmt.Sprintf(`affinity: {} + +# use this value to override the chart name +fullnameOverride: "" + +# use this value to set the image pull policy +image: + pullPolicy: IfNotPresent +%s: + license: online-license + appName: online-app-name + channelID: online-channel-id + channelName: online-channel-name + channelSequence: 2 + releaseCreatedAt: "2023-10-02T00:00:00Z" + releaseNotes: override my release notes + releaseSequence: 1 + statusInformers: + - deployment/replicated + - service/replicated + versionLabel: 1.0.0 +global: + replicated: + licenseID: online-license-id + channelName: online-channel-name + customerName: Online Customer Name + customerEmail: online-customer@example.com + licenseType: dev + dockerconfigjson: bm90LWEtZG9ja2VyLWNvbmZpZy1qc29uCg== + licenseFields: + expires_at: + name: expires_at + title: Expiration + description: License Expiration + value: "" + valueType: String + signature: + v1: nwZmD/sMFzKKxkd7JaAcKU/2uBE5m23w7+8xqLMXjUturMVCF5cF66EVMAibb2nHOqytie+N35GYSwIeTd16PKwbFBDd12c2E5M9COWwjVRcVTz4OnNWmHv9PEqZIbXhvfCLlyJ/aY3zV9Pno1VLFcYxGMrBugncEo4ecHkEbaVp3VLS4wn8EykAC1byvYBshzEXppYYd3c6a9cNw50Z6inI/IaKVxIForuz+Yn5uRAsjRyCY2auBCMeHMhY+CQ+4Vl5WtGjuJuE1g7t8AVZqt2JDBgDuxZAZX/JGncfzUaaDl87athMTtBKnFkTnCl34UXPkhsgM0LC4YoUiyKYjQ== +`, chartName), + "guestbook/charts/replicated/Chart.yaml": fmt.Sprintf(`apiVersion: v1 +name: %s +version: 1.0.0 +description: A Replicated Chart +`, chartName), + "guestbook/charts/replicated/values.yaml": `# preserve this comment + +channelName: keep-this-channel-name +# and this comment + +some: value +# and this comment as well +`, + }, + want: map[string]string{ + "guestbook/Chart.yaml": `apiVersion: v2 +name: guestbook +version: 1.16.0 +description: A Guestbook Chart +`, + "guestbook/values.yaml": fmt.Sprintf(`affinity: {} +# use this value to override the chart name +fullnameOverride: "" +# use this value to set the image pull policy +image: + pullPolicy: IfNotPresent +%s: + license: online-license + appName: online-app-name + channelID: online-channel-id + channelName: online-channel-name + channelSequence: 2 + releaseCreatedAt: "2023-10-02T00:00:00Z" + releaseNotes: override my release notes + releaseSequence: 1 + statusInformers: + - deployment/replicated + - service/replicated + versionLabel: 1.0.0 + appID: app-id + isAirgap: false + replicatedID: kotsadm-id + userAgent: KOTS/v0.0.0-unknown +global: + replicated: + licenseID: online-license-id + channelName: online-channel-name + customerName: Online Customer Name + customerEmail: online-customer@example.com + licenseType: dev + dockerconfigjson: bm90LWEtZG9ja2VyLWNvbmZpZy1qc29uCg== + licenseFields: + expires_at: + name: expires_at + title: Expiration + description: License Expiration + value: "" + valueType: String + signature: + v1: nwZmD/sMFzKKxkd7JaAcKU/2uBE5m23w7+8xqLMXjUturMVCF5cF66EVMAibb2nHOqytie+N35GYSwIeTd16PKwbFBDd12c2E5M9COWwjVRcVTz4OnNWmHv9PEqZIbXhvfCLlyJ/aY3zV9Pno1VLFcYxGMrBugncEo4ecHkEbaVp3VLS4wn8EykAC1byvYBshzEXppYYd3c6a9cNw50Z6inI/IaKVxIForuz+Yn5uRAsjRyCY2auBCMeHMhY+CQ+4Vl5WtGjuJuE1g7t8AVZqt2JDBgDuxZAZX/JGncfzUaaDl87athMTtBKnFkTnCl34UXPkhsgM0LC4YoUiyKYjQ== +`, chartName), + "guestbook/charts/replicated/Chart.yaml": fmt.Sprintf(`apiVersion: v1 +name: %s +version: 1.0.0 +description: A Replicated Chart +`, chartName), + "guestbook/charts/replicated/values.yaml": `# preserve this comment + +channelName: keep-this-channel-name +# and this comment + +some: value +# and this comment as well +`, + }, + wantErr: false, + }) + + tests = append(tests, Test{ + name: "airgap - a guestbook chart with the replicated subchart", + isAirgap: true, + chartContent: map[string]string{ + "guestbook/Chart.yaml": `apiVersion: v2 +name: guestbook +version: 1.16.0 +description: A Guestbook Chart +`, + "guestbook/values.yaml": fmt.Sprintf(`affinity: {} + +# use this value to override the chart name +fullnameOverride: "" + +# use this value to set the image pull policy +image: + pullPolicy: IfNotPresent +%s: + appName: app-name + channelID: channel-id + channelName: channel-name + channelSequence: 2 + releaseCreatedAt: "2023-10-02T00:00:00Z" + releaseNotes: override my release notes + releaseSequence: 1 + statusInformers: + - deployment/replicated + - service/replicated + versionLabel: 1.0.0 +`, chartName), + "guestbook/charts/replicated/Chart.yaml": fmt.Sprintf(`apiVersion: v1 +name: %s +version: 1.0.0 +description: A Replicated Chart +`, chartName), + "guestbook/charts/replicated/values.yaml": `# preserve this comment + +channelName: keep-this-channel-name +# and this comment + +some: value +# and this comment as well +`, + }, + want: map[string]string{ + "guestbook/Chart.yaml": `apiVersion: v2 +name: guestbook +version: 1.16.0 +description: A Guestbook Chart +`, + "guestbook/values.yaml": fmt.Sprintf(`affinity: {} +# use this value to override the chart name +fullnameOverride: "" +# use this value to set the image pull policy +image: + pullPolicy: IfNotPresent +%s: + appName: app-name + channelID: channel-id + channelName: channel-name + channelSequence: 2 + releaseCreatedAt: "2023-10-02T00:00:00Z" + releaseNotes: override my release notes + releaseSequence: 1 + statusInformers: + - deployment/replicated + - service/replicated + versionLabel: 1.0.0 + appID: app-id + isAirgap: true + license: | + apiVersion: kots.io/v1beta1 + kind: License + metadata: + creationTimestamp: null + name: kots-license + 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: dev + signature: "" + status: {} + replicatedID: kotsadm-id + userAgent: KOTS/v0.0.0-unknown +global: + replicated: + channelName: channel-name + customerEmail: customer@example.com + customerName: Customer Name + dockerconfigjson: eyJhdXRocyI6eyJjdXN0b20ucHJveHkuY29tIjp7ImF1dGgiOiJiR2xqWlc1elpTMXBaRHBzYVdObGJuTmxMV2xrIn0sImN1c3RvbS5yZWdpc3RyeS5jb20iOnsiYXV0aCI6ImJHbGpaVzV6WlMxcFpEcHNhV05sYm5ObExXbGsifX19 + licenseFields: + license-field: + description: This is a license field + name: license-field + title: License Field + value: license-field-value + valueType: string + licenseID: license-id + licenseType: dev +`, chartName), + "guestbook/charts/replicated/Chart.yaml": fmt.Sprintf(`apiVersion: v1 +name: %s +version: 1.0.0 +description: A Replicated Chart +`, chartName), + "guestbook/charts/replicated/values.yaml": `# preserve this comment + +channelName: keep-this-channel-name +# and this comment + +some: value +# and this comment as well +`, + }, + wantErr: false, + }) + + tests = append(tests, Test{ + name: "online - a redis chart with the replicated subchart and predefined replicated and global values", + isAirgap: false, + chartContent: map[string]string{ + "redis/Chart.yaml": `apiVersion: v1 +name: redis +version: 5.0.7 +description: A Redis Chart +`, + "redis/values.yaml": fmt.Sprintf(`## Global Docker image parameters +## Please, note that this will override the image parameters, including dependencies, configured to use the global value +## Current available global Docker image parameters: imageRegistry and imagePullSecrets +## +global: + # imageRegistry: myRegistryName + # imagePullSecrets: + # - myRegistryKeySecretName + # storageClass: myStorageClass + redis: {} + replicated: + some: value + licenseID: online-license-id + channelName: online-channel-name + customerName: Online Customer Name + customerEmail: online-customer@example.com + licenseType: dev + dockerconfigjson: bm90LWEtZG9ja2VyLWNvbmZpZy1qc29uCg== + licenseFields: + expires_at: + name: expires_at + title: Expiration + description: License Expiration + value: "" + valueType: String + signature: + v1: nwZmD/sMFzKKxkd7JaAcKU/2uBE5m23w7+8xqLMXjUturMVCF5cF66EVMAibb2nHOqytie+N35GYSwIeTd16PKwbFBDd12c2E5M9COWwjVRcVTz4OnNWmHv9PEqZIbXhvfCLlyJ/aY3zV9Pno1VLFcYxGMrBugncEo4ecHkEbaVp3VLS4wn8EykAC1byvYBshzEXppYYd3c6a9cNw50Z6inI/IaKVxIForuz+Yn5uRAsjRyCY2auBCMeHMhY+CQ+4Vl5WtGjuJuE1g7t8AVZqt2JDBgDuxZAZX/JGncfzUaaDl87athMTtBKnFkTnCl34UXPkhsgM0LC4YoUiyKYjQ== + +# values related to the replicated subchart +%s: + some: value + license: online-license + appName: online-app-name + channelID: online-channel-id + channelName: online-channel-name + channelSequence: 2 + releaseCreatedAt: "2023-10-02T00:00:00Z" + releaseNotes: override my release notes + releaseSequence: 1 + statusInformers: + - deployment/replicated + - service/replicated + versionLabel: 1.0.0 +`, chartName), + "redis/charts/replicated/Chart.yaml": fmt.Sprintf(`apiVersion: v1 +name: %s +version: 1.0.0 +description: A Replicated Chart +`, chartName), + "redis/charts/replicated/values.yaml": `# preserve this comment + +channelName: keep-this-channel-name +# and this comment + +some: value +# and this comment as well +`, + }, + want: map[string]string{ + "redis/Chart.yaml": `apiVersion: v1 +name: redis +version: 5.0.7 +description: A Redis Chart +`, + "redis/values.yaml": fmt.Sprintf(`## Global Docker image parameters +## Please, note that this will override the image parameters, including dependencies, configured to use the global value +## Current available global Docker image parameters: imageRegistry and imagePullSecrets +## +global: + # imageRegistry: myRegistryName + # imagePullSecrets: + # - myRegistryKeySecretName + # storageClass: myStorageClass + redis: {} + replicated: + some: value + licenseID: online-license-id + channelName: online-channel-name + customerName: Online Customer Name + customerEmail: online-customer@example.com + licenseType: dev + dockerconfigjson: bm90LWEtZG9ja2VyLWNvbmZpZy1qc29uCg== + licenseFields: + expires_at: + name: expires_at + title: Expiration + description: License Expiration + value: "" + valueType: String + signature: + v1: nwZmD/sMFzKKxkd7JaAcKU/2uBE5m23w7+8xqLMXjUturMVCF5cF66EVMAibb2nHOqytie+N35GYSwIeTd16PKwbFBDd12c2E5M9COWwjVRcVTz4OnNWmHv9PEqZIbXhvfCLlyJ/aY3zV9Pno1VLFcYxGMrBugncEo4ecHkEbaVp3VLS4wn8EykAC1byvYBshzEXppYYd3c6a9cNw50Z6inI/IaKVxIForuz+Yn5uRAsjRyCY2auBCMeHMhY+CQ+4Vl5WtGjuJuE1g7t8AVZqt2JDBgDuxZAZX/JGncfzUaaDl87athMTtBKnFkTnCl34UXPkhsgM0LC4YoUiyKYjQ== +# values related to the replicated subchart +%s: + some: value + license: online-license + appName: online-app-name + channelID: online-channel-id + channelName: online-channel-name + channelSequence: 2 + releaseCreatedAt: "2023-10-02T00:00:00Z" + releaseNotes: override my release notes + releaseSequence: 1 + statusInformers: + - deployment/replicated + - service/replicated + versionLabel: 1.0.0 + appID: app-id + isAirgap: false + replicatedID: kotsadm-id + userAgent: KOTS/v0.0.0-unknown +`, chartName), + "redis/charts/replicated/Chart.yaml": fmt.Sprintf(`apiVersion: v1 +name: %s +version: 1.0.0 +description: A Replicated Chart +`, chartName), + "redis/charts/replicated/values.yaml": `# preserve this comment + +channelName: keep-this-channel-name +# and this comment + +some: value +# and this comment as well +`, + }, + wantErr: false, + }) + + tests = append(tests, Test{ + name: "airgap - a redis chart with the replicated subchart and predefined replicated and global values", + isAirgap: true, + chartContent: map[string]string{ + "redis/Chart.yaml": `apiVersion: v1 +name: redis +version: 5.0.7 +description: A Redis Chart +`, + "redis/values.yaml": fmt.Sprintf(`## Global Docker image parameters +## Please, note that this will override the image parameters, including dependencies, configured to use the global value +## Current available global Docker image parameters: imageRegistry and imagePullSecrets +## +global: + # imageRegistry: myRegistryName + # imagePullSecrets: + # - myRegistryKeySecretName + # storageClass: myStorageClass + redis: {} + replicated: + some: value + +# values related to the replicated subchart +%s: + some: value + appName: app-name + channelID: channel-id + channelName: channel-name + channelSequence: 2 + releaseCreatedAt: "2023-10-02T00:00:00Z" + releaseNotes: override my release notes + releaseSequence: 1 + statusInformers: + - deployment/replicated + - service/replicated + versionLabel: 1.0.0 +`, chartName), + "redis/charts/replicated/Chart.yaml": fmt.Sprintf(`apiVersion: v1 +name: %s +version: 1.0.0 +description: A Replicated Chart +`, chartName), + "redis/charts/replicated/values.yaml": `# preserve this comment + +channelName: keep-this-channel-name +# and this comment + +some: value +# and this comment as well +`, + }, + want: map[string]string{ + "redis/Chart.yaml": `apiVersion: v1 +name: redis +version: 5.0.7 +description: A Redis Chart +`, + "redis/values.yaml": fmt.Sprintf(`## Global Docker image parameters +## Please, note that this will override the image parameters, including dependencies, configured to use the global value +## Current available global Docker image parameters: imageRegistry and imagePullSecrets +## +global: + # imageRegistry: myRegistryName + # imagePullSecrets: + # - myRegistryKeySecretName + # storageClass: myStorageClass + redis: {} + replicated: + some: value + channelName: channel-name + customerEmail: customer@example.com + customerName: Customer Name + dockerconfigjson: eyJhdXRocyI6eyJjdXN0b20ucHJveHkuY29tIjp7ImF1dGgiOiJiR2xqWlc1elpTMXBaRHBzYVdObGJuTmxMV2xrIn0sImN1c3RvbS5yZWdpc3RyeS5jb20iOnsiYXV0aCI6ImJHbGpaVzV6WlMxcFpEcHNhV05sYm5ObExXbGsifX19 + licenseFields: + license-field: + description: This is a license field + name: license-field + title: License Field + value: license-field-value + valueType: string + licenseID: license-id + licenseType: dev +# values related to the replicated subchart +%s: + some: value + appName: app-name + channelID: channel-id + channelName: channel-name + channelSequence: 2 + releaseCreatedAt: "2023-10-02T00:00:00Z" + releaseNotes: override my release notes + releaseSequence: 1 + statusInformers: + - deployment/replicated + - service/replicated + versionLabel: 1.0.0 + appID: app-id + isAirgap: true + license: | + apiVersion: kots.io/v1beta1 + kind: License + metadata: + creationTimestamp: null + name: kots-license + 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: dev + signature: "" + status: {} + replicatedID: kotsadm-id + userAgent: KOTS/v0.0.0-unknown +`, chartName), + "redis/charts/replicated/Chart.yaml": fmt.Sprintf(`apiVersion: v1 +name: %s +version: 1.0.0 +description: A Replicated Chart +`, chartName), + "redis/charts/replicated/values.yaml": `# preserve this comment + +channelName: keep-this-channel-name +# and this comment + +some: value +# and this comment as well +`, + }, + wantErr: false, + }) + + tests = append(tests, Test{ + name: "online - a postgresql chart with replicated as subsubchart", + isAirgap: false, + chartContent: map[string]string{ + "postgresql/Chart.yaml": `apiVersion: v2 +name: postgresql +version: 11.6.0 +description: A Postgresql Chart +`, + "postgresql/values.yaml": `extraEnv: [] + +# override global values here +global: + postgresql: {} + +# additional values can be added here +`, + "postgresql/charts/guestbook/Chart.yaml": `apiVersion: v2 +name: guestbook +version: 1.16.0 +description: A Guestbook Chart +`, + "postgresql/charts/guestbook/values.yaml": `affinity: {} + +# use this value to override the chart name +fullnameOverride: "" + +# use this value to set the image pull policy +image: + pullPolicy: IfNotPresent +`, + "postgresql/charts/guestbook/charts/replicated/Chart.yaml": fmt.Sprintf(`apiVersion: v1 +name: %s +version: 1.0.0 +description: A Replicated Chart +`, chartName), + "postgresql/charts/guestbook/charts/replicated/values.yaml": `# this file should NOT change + +# global values should NOT be updated +global: + keep: this-value + +channelName: keep-this-channel-name +# and this comment + +some: value +# and this comment as well +`, + }, + want: map[string]string{ + "postgresql/Chart.yaml": `apiVersion: v2 +name: postgresql +version: 11.6.0 +description: A Postgresql Chart +`, + "postgresql/values.yaml": `extraEnv: [] + +# override global values here +global: + postgresql: {} + +# additional values can be added here +`, + "postgresql/charts/guestbook/Chart.yaml": `apiVersion: v2 +name: guestbook +version: 1.16.0 +description: A Guestbook Chart +`, + "postgresql/charts/guestbook/values.yaml": `affinity: {} + +# use this value to override the chart name +fullnameOverride: "" + +# use this value to set the image pull policy +image: + pullPolicy: IfNotPresent +`, + "postgresql/charts/guestbook/charts/replicated/Chart.yaml": fmt.Sprintf(`apiVersion: v1 +name: %s +version: 1.0.0 +description: A Replicated Chart +`, chartName), + "postgresql/charts/guestbook/charts/replicated/values.yaml": `# this file should NOT change + +# global values should NOT be updated +global: + keep: this-value + +channelName: keep-this-channel-name +# and this comment + +some: value +# and this comment as well +`, + }, + }) + + tests = append(tests, Test{ + name: "airgap - a postgresql chart with replicated as subsubchart", + isAirgap: true, + chartContent: map[string]string{ + "postgresql/Chart.yaml": `apiVersion: v2 +name: postgresql +version: 11.6.0 +description: A Postgresql Chart +`, + "postgresql/values.yaml": `extraEnv: [] + +# override global values here +global: + postgresql: {} + +# additional values can be added here +`, + "postgresql/charts/guestbook/Chart.yaml": `apiVersion: v2 +name: guestbook +version: 1.16.0 +description: A Guestbook Chart +`, + "postgresql/charts/guestbook/values.yaml": `affinity: {} + +# use this value to override the chart name +fullnameOverride: "" + +# use this value to set the image pull policy +image: + pullPolicy: IfNotPresent +`, + "postgresql/charts/guestbook/charts/replicated/Chart.yaml": fmt.Sprintf(`apiVersion: v1 +name: %s +version: 1.0.0 +description: A Replicated Chart +`, chartName), + "postgresql/charts/guestbook/charts/replicated/values.yaml": `# this file should NOT change + +# global values should NOT be updated +global: + keep: this-value + +channelName: keep-this-channel-name +# and this comment + +some: value +# and this comment as well +`, + }, + want: map[string]string{ + "postgresql/Chart.yaml": `apiVersion: v2 +name: postgresql +version: 11.6.0 +description: A Postgresql Chart +`, + "postgresql/values.yaml": `extraEnv: [] + +# override global values here +global: + postgresql: {} + +# additional values can be added here +`, + "postgresql/charts/guestbook/Chart.yaml": `apiVersion: v2 +name: guestbook +version: 1.16.0 +description: A Guestbook Chart +`, + "postgresql/charts/guestbook/values.yaml": `affinity: {} + +# use this value to override the chart name +fullnameOverride: "" + +# use this value to set the image pull policy +image: + pullPolicy: IfNotPresent +`, + "postgresql/charts/guestbook/charts/replicated/Chart.yaml": fmt.Sprintf(`apiVersion: v1 +name: %s +version: 1.0.0 +description: A Replicated Chart +`, chartName), + "postgresql/charts/guestbook/charts/replicated/values.yaml": `# this file should NOT change + +# global values should NOT be updated +global: + keep: this-value + +channelName: keep-this-channel-name +# and this comment + +some: value +# and this comment as well +`, + }, + }) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chartBytes, err := util.FilesToTGZ(tt.chartContent) + require.NoError(t, err) + + upstream := &types.Upstream{ + License: &kotsv1beta1.License{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "kots.io/v1beta1", + Kind: "License", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "kots-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: "dev", + Signature: []byte{}, + }, + }, + ReplicatedRegistryDomain: "custom.registry.com", + ReplicatedProxyDomain: "custom.proxy.com", + ReplicatedChartNames: testReplicatedChartNames, + } + + writeOptions := types.WriteOptions{ + KotsadmID: "kotsadm-id", + AppID: "app-id", + IsAirgap: tt.isAirgap, + } + + got, err := configureChart(chartBytes, upstream, writeOptions) + if (err != nil) != tt.wantErr { + t.Errorf("configureChart() error = %v, wantErr %v", err, tt.wantErr) + return + } + + gotFiles, err := util.TGZToFiles(got) + require.NoError(t, err) + + for filename, wantContent := range tt.want { + gotContent := gotFiles[filename] + if gotContent != wantContent { + t.Errorf("configureChart() %s: %v", filename, diffString(gotContent, wantContent)) + } + } + }) + } +} + +func diffString(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) +} 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..6f24527b6f 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 } @@ -67,6 +69,9 @@ type WriteOptions struct { NoProxyEnvValue string IsMinimalRBAC bool AdditionalNamespaces []string + IsAirgap bool + KotsadmID string + AppID string // This should be set to true when updating due to license sync, config update, registry settings update. // and should be false when it's an upstream update. // When true, the channel name in Installation yaml will not be changed. @@ -98,6 +103,7 @@ type FetchOptions struct { 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..2c8d8f8fb9 100644 --- a/pkg/upstream/write.go +++ b/pkg/upstream/write.go @@ -3,11 +3,11 @@ package upstream import ( "bytes" "encoding/base64" - "io/ioutil" "os" "path" "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/archives" "github.com/replicatedhq/kots/pkg/crypto" "github.com/replicatedhq/kots/pkg/kotsutil" "github.com/replicatedhq/kots/pkg/upstream/types" @@ -39,7 +39,7 @@ func WriteUpstream(u *types.Upstream, options types.WriteOptions) error { if err == nil { _, err = os.Stat(path.Join(renderDir, "userdata", "installation.yaml")) if err == nil { - c, err := ioutil.ReadFile(path.Join(renderDir, "userdata", "installation.yaml")) + c, err := os.ReadFile(path.Join(renderDir, "userdata", "installation.yaml")) if err != nil { return errors.Wrap(err, "failed to read existing installation") } @@ -100,7 +100,16 @@ 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) { + updatedContent, err := configureChart(file.Content, u, options) + 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 +140,7 @@ func WriteUpstream(u *types.Upstream, options types.WriteOptions) error { ReleaseNotes: u.ReleaseNotes, ReplicatedRegistryDomain: u.ReplicatedRegistryDomain, ReplicatedProxyDomain: u.ReplicatedProxyDomain, + ReplicatedChartNames: u.ReplicatedChartNames, EncryptionKey: encryptionKey, }, } @@ -147,7 +157,7 @@ func WriteUpstream(u *types.Upstream, options types.WriteOptions) error { } installationBytes := kotsutil.MustMarshalInstallation(&installation) - err = ioutil.WriteFile(path.Join(renderDir, "userdata", "installation.yaml"), installationBytes, 0644) + err = os.WriteFile(path.Join(renderDir, "userdata", "installation.yaml"), installationBytes, 0644) if err != nil { return errors.Wrap(err, "failed to write installation") } diff --git a/pkg/util/file.go b/pkg/util/file.go new file mode 100644 index 0000000000..5583a2a3ed --- /dev/null +++ b/pkg/util/file.go @@ -0,0 +1,74 @@ +package util + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "io" + "strings" + + "github.com/pkg/errors" +) + +func FilesToTGZ(files map[string]string) ([]byte, error) { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + tw := tar.NewWriter(gw) + + for path, content := range files { + header := &tar.Header{ + Name: path, + Mode: 0644, + Size: int64(len(content)), + } + if err := tw.WriteHeader(header); err != nil { + return nil, errors.Wrapf(err, "failed to write tar header for %s", path) + } + _, err := io.Copy(tw, strings.NewReader(content)) + if err != nil { + return nil, errors.Wrapf(err, "failed to write %s to tar", path) + } + } + + if err := tw.Close(); err != nil { + return nil, errors.Wrap(err, "failed to close tar writer") + } + + if err := gw.Close(); err != nil { + return nil, errors.Wrap(err, "failed to close gzip writer") + } + + return buf.Bytes(), nil +} + +func TGZToFiles(tgzBytes []byte) (map[string]string, error) { + files := make(map[string]string) + + gr, err := gzip.NewReader(bytes.NewReader(tgzBytes)) + if err != nil { + return nil, errors.Wrap(err, "failed to create gzip reader") + } + defer gr.Close() + + tr := tar.NewReader(gr) + + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, err + } + + if header.Typeflag == tar.TypeReg { + var contentBuf bytes.Buffer + if _, err := io.Copy(&contentBuf, tr); err != nil { + return nil, errors.Wrap(err, "failed to copy tar data") + } + files[header.Name] = contentBuf.String() + } + } + + return files, nil +} diff --git a/pkg/util/file_test.go b/pkg/util/file_test.go new file mode 100644 index 0000000000..31e188b3c9 --- /dev/null +++ b/pkg/util/file_test.go @@ -0,0 +1,51 @@ +package util + +import ( + "reflect" + "testing" +) + +func Test_filesToTGZAndTGZToFiles(t *testing.T) { + tests := []struct { + name string + files map[string]string + }{ + { + name: "SingleFile", + files: map[string]string{ + "file.txt": "File content", + }, + }, + { + name: "MultipleFiles", + files: map[string]string{ + "file1.txt": "File 1 content", + "file2.txt": "File 2 content", + }, + }, + { + name: "EmptyFiles", + files: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tgzBytes, err := FilesToTGZ(tt.files) + if err != nil { + t.Errorf("FilesToTGZ() error = %v", err) + return + } + + actualFiles, err := TGZToFiles(tgzBytes) + if err != nil { + t.Errorf("TGZToFiles() error = %v", err) + return + } + + if !reflect.DeepEqual(actualFiles, tt.files) { + t.Errorf("filesToTGZAndTGZToFiles() = %v, want %v", actualFiles, tt.files) + } + }) + } +} diff --git a/web/src/Root.tsx b/web/src/Root.tsx index 5bbe6f3a13..2e7930a438 100644 --- a/web/src/Root.tsx +++ b/web/src/Root.tsx @@ -9,7 +9,8 @@ import GitOps from "./components/clusters/GitOps"; import PreflightResultPage from "./components/PreflightResultPage"; import AppConfig from "./features/AppConfig/components/AppConfig"; import { AppDetailPage } from "./components/apps/AppDetailPage"; -import ClusterNodes from "./components/apps/ClusterNodes"; +import KurlClusterManagement from "./components/apps/KurlClusterManagement"; +import HelmVMClusterManagement from "./components/apps/HelmVMClusterManagement"; import UnsupportedBrowser from "./components/static/UnsupportedBrowser"; import NotFound from "./components/static/NotFound"; import { Utilities, parseUpstreamUri } from "./utilities/utilities"; @@ -458,13 +459,14 @@ const Root = () => { }} > - {/* eslint-disable-next-line */} - {/* @ts-ignore */} + {/* eslint-disable-next-line */} + {/* @ts-ignore */} { } /> } + element={ + state.adminConsoleMetadata?.isKurl ? ( + + ) : ( + + ) + } /> { { + try { + const res = await fetch(`${process.env.API_ENDPOINT}/helmvm/nodes`, { + headers: { + Accept: "application/json", + }, + credentials: "include", + method: "GET", + }); + if (!res.ok) { + if (res.status === 401) { + Utilities.logoutUser(); + return; + } + console.log( + "failed to get node status list, unexpected status code", + res.status + ); + return; + } + const response = await res.json(); + this.setState({ + helmvm: response, + // if cluster doesn't support ha, then primary will be disabled. Force into secondary + selectedNodeType: !response.ha + ? "secondary" + : this.state.selectedNodeType, + }); + return response; + } catch (err) { + console.log(err); + throw err; + } + }; + + deleteNode = (name) => { + this.setState({ + confirmDeleteNode: name, + }); + }; + + cancelDeleteNode = () => { + this.setState({ + confirmDeleteNode: "", + }); + }; + + reallyDeleteNode = () => { + const name = this.state.confirmDeleteNode; + this.cancelDeleteNode(); + + fetch(`${process.env.API_ENDPOINT}/helmvm/nodes/${name}`, { + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + credentials: "include", + method: "DELETE", + }) + .then(async (res) => { + if (!res.ok) { + if (res.status === 401) { + Utilities.logoutUser(); + return; + } + this.setState({ + deleteNodeError: `Delete failed with status ${res.status}`, + }); + } + }) + .catch((err) => { + console.log(err); + }); + }; + + generateWorkerAddNodeCommand = async () => { + this.setState({ + generating: true, + command: "", + expiry: null, + generateCommandErrMsg: "", + }); + + fetch( + `${process.env.API_ENDPOINT}/helmvm/generate-node-join-command-secondary`, + { + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + credentials: "include", + method: "POST", + } + ) + .then(async (res) => { + if (!res.ok) { + this.setState({ + generating: false, + generateCommandErrMsg: `Failed to generate command with status ${res.status}`, + }); + } else { + const data = await res.json(); + this.setState({ + generating: false, + command: data.command, + expiry: data.expiry, + }); + } + }) + .catch((err) => { + console.log(err); + this.setState({ + generating: false, + generateCommandErrMsg: err ? err.message : "Something went wrong", + }); + }); + }; + + onDrainNodeClick = (name) => { + this.setState({ + showConfirmDrainModal: true, + nodeNameToDrain: name, + }); + }; + + drainNode = async (name) => { + this.setState({ showConfirmDrainModal: false, drainingNodeName: name }); + fetch(`${process.env.API_ENDPOINT}/helmvm/nodes/${name}/drain`, { + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + credentials: "include", + method: "POST", + }) + .then(async (res) => { + this.setState({ drainNodeSuccessful: true }); + setTimeout(() => { + this.setState({ + drainingNodeName: null, + drainNodeSuccessful: false, + }); + }, 3000); + }) + .catch((err) => { + console.log(err); + this.setState({ + drainingNodeName: null, + drainNodeSuccessful: false, + }); + }); + }; + + generatePrimaryAddNodeCommand = async () => { + this.setState({ + generating: true, + command: "", + expiry: null, + generateCommandErrMsg: "", + }); + + fetch( + `${process.env.API_ENDPOINT}/helmvm/generate-node-join-command-primary`, + { + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + credentials: "include", + method: "POST", + } + ) + .then(async (res) => { + if (!res.ok) { + this.setState({ + generating: false, + generateCommandErrMsg: `Failed to generate command with status ${res.status}`, + }); + } else { + const data = await res.json(); + this.setState({ + generating: false, + command: data.command, + expiry: data.expiry, + }); + } + }) + .catch((err) => { + console.log(err); + this.setState({ + generating: false, + generateCommandErrMsg: err ? err.message : "Something went wrong", + }); + }); + }; + + onAddNodeClick = () => { + this.setState( + { + displayAddNode: true, + }, + async () => { + await this.generateWorkerAddNodeCommand(); + } + ); + }; + + onSelectNodeType = (event) => { + const value = event.currentTarget.value; + this.setState( + { + selectedNodeType: value, + }, + async () => { + if (this.state.selectedNodeType === "secondary") { + await this.generateWorkerAddNodeCommand(); + } else { + await this.generatePrimaryAddNodeCommand(); + } + } + ); + }; + + ackDeleteNodeError = () => { + this.setState({ deleteNodeError: "" }); + }; + + render() { + const { helmvm } = this.state; + const { displayAddNode, generateCommandErrMsg } = this.state; + + if (!helmvm) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+
+
+

+ Your nodes +

+
+ {helmvm?.nodes && + helmvm?.nodes.map((node, i) => ( + + ))} +
+
+ {helmvm?.isHelmVMEnabled && + Utilities.sessionRolesHasOneOf([rbacRoles.CLUSTER_ADMIN]) ? ( + !displayAddNode ? ( +
+ +
+ ) : ( +
+
+

+ Add a Node +

+
+
+
+ + +
+
+ + +
+
+ {this.state.generating && ( +
+ +
+ )} + {!this.state.generating && this.state.command?.length > 0 ? ( + +

+ Run this command on the node you wish to join the + cluster +

+ + Command has been copied to your clipboard + + } + > + {[this.state.command.join(" \\\n ")]} + + {this.state.expiry && ( + + {`Expires on ${dayjs(this.state.expiry).format( + "MMM Do YYYY, h:mm:ss a z" + )} UTC${(-1 * new Date().getTimezoneOffset()) / 60}`} + + )} +
+ ) : ( + + {generateCommandErrMsg && ( +
+ + {generateCommandErrMsg} + +
+ )} +
+ )} +
+ ) + ) : null} +
+
+ {this.state.deleteNodeError && ( + + )} + +
+

+ Deleting this node may cause data loss. Are you sure you want to + proceed? +

+
+ + +
+
+
+ {this.state.showConfirmDrainModal && ( + + this.setState({ + showConfirmDrainModal: false, + nodeNameToDrain: "", + }) + } + shouldReturnFocusAfterClose={false} + contentLabel="Confirm Drain Node" + ariaHideApp={false} + className="Modal MediumSize" + > +
+

+ Are you sure you want to drain {this.state.nodeNameToDrain}? +

+

+ Draining this node may cause data loss. If you want to delete{" "} + {this.state.nodeNameToDrain} you must disconnect it after it has + been drained. +

+
+ + +
+
+
+ )} +
+ ); + } +} + +export default HelmVMClusterManagement; diff --git a/web/src/components/apps/HelmVMNodeRow.jsx b/web/src/components/apps/HelmVMNodeRow.jsx new file mode 100644 index 0000000000..93f2c5489c --- /dev/null +++ b/web/src/components/apps/HelmVMNodeRow.jsx @@ -0,0 +1,278 @@ +import React from "react"; +import classNames from "classnames"; +import Loader from "../shared/Loader"; +import { rbacRoles } from "../../constants/rbac"; +import { getPercentageStatus, Utilities } from "../../utilities/utilities"; +import Icon from "../Icon"; + +export default function HelmVMNodeRow(props) { + const { node } = props; + + const DrainDeleteNode = () => { + const { drainNode, drainNodeSuccessful, drainingNodeName } = props; + if (drainNode && Utilities.sessionRolesHasOneOf(rbacRoles.DRAIN_NODE)) { + if ( + !drainNodeSuccessful && + drainingNodeName && + drainingNodeName === node?.name + ) { + return ( +
+ + + + + Draining Node + +
+ ); + } else if (drainNodeSuccessful && drainingNodeName === node?.name) { + return ( +
+ + + Node successfully drained + +
+ ); + } else { + return ( +
+ +
+ ); + } + } + }; + + return ( +
+
+
+

+ {node?.name} +

+ {node?.isPrimaryNode && ( + + Primary node + + )} +
+
+
+

+ + {node?.isConnected ? "Connected" : "Disconnected"} +

+

+   +

+
+
+

+ + {node?.pods?.available === -1 + ? `${node?.pods?.capacity} pods` + : `${ + node?.pods?.available === 0 + ? "0" + : node?.pods?.capacity - node?.pods?.available + } pods used`} +

+ {node?.pods?.available !== -1 && ( +

+ of {node?.pods?.capacity} pods total +

+ )} +
+
+

+ + {node?.cpu?.available === -1 + ? `${node?.cpu?.capacity} ${ + node?.cpu?.available === "1" ? "core" : "cores" + }` + : `${ + node?.cpu?.available === 0 + ? "0" + : (node?.cpu?.capacity - node?.cpu?.available).toFixed(1) + } ${ + node?.cpu?.available === "1" ? "core used" : "cores used" + }`} +

+ {node?.pods?.available !== -1 && ( +

+ of {node?.cpu?.capacity}{" "} + {node?.cpu?.available === "1" ? "core total" : "cores total"} +

+ )} +
+
+

+ + {node?.memory?.available === -1 + ? `${node?.memory?.capacity?.toFixed(1)} GB` + : `${ + node?.memory?.available === 0 + ? "0" + : ( + node?.memory?.capacity - node?.memory?.available + ).toFixed(1) + } GB used`} +

+ {node?.pods?.available !== -1 && ( +

+ of {node?.memory?.capacity?.toFixed(1)} GB total +

+ )} +
+
+
+
+

+ + {node?.kubeletVersion} +

+
+
+

+ + {node?.conditions?.diskPressure + ? "No Space on Device" + : "No Disk Pressure"} +

+
+
+

+ + {node?.conditions?.pidPressure + ? "Pressure on CPU" + : "No CPU Pressure"} +

+
+
+

+ + {node?.conditions?.memoryPressure + ? "No Space on Memory" + : "No Memory Pressure"} +

+
+
+ {/* LABELS */} +
+ {node?.labels.length > 0 + ? node.labels.sort().map((label, i) => { + let labelToShow = label.replace(":", "="); + return ( +
+ {labelToShow} +
+ ); + }) + : null} +
+
+

+ For more details run{" "} + + kubectl describe node {node?.name} + +

+
+
+ +
+ ); +} diff --git a/web/src/components/apps/HelmVMNodeRow.test.js b/web/src/components/apps/HelmVMNodeRow.test.js new file mode 100644 index 0000000000..b7a4d8324c --- /dev/null +++ b/web/src/components/apps/HelmVMNodeRow.test.js @@ -0,0 +1,3 @@ +describe("HelmVMNodeRow", () => { + it.todo("upgrade to react 18 and add unit tests"); +}); diff --git a/web/src/components/apps/ClusterNodes.jsx b/web/src/components/apps/KurlClusterManagement.jsx similarity index 98% rename from web/src/components/apps/ClusterNodes.jsx rename to web/src/components/apps/KurlClusterManagement.jsx index d5307df3e0..46f6ade1fd 100644 --- a/web/src/components/apps/ClusterNodes.jsx +++ b/web/src/components/apps/KurlClusterManagement.jsx @@ -3,7 +3,7 @@ import classNames from "classnames"; import dayjs from "dayjs"; import { KotsPageTitle } from "@components/Head"; import CodeSnippet from "../shared/CodeSnippet"; -import NodeRow from "./NodeRow"; +import KurlNodeRow from "./KurlNodeRow"; import Loader from "../shared/Loader"; import { rbacRoles } from "../../constants/rbac"; import { Utilities } from "../../utilities/utilities"; @@ -11,10 +11,10 @@ import { Repeater } from "../../utilities/repeater"; import ErrorModal from "../modals/ErrorModal"; import Modal from "react-modal"; -import "@src/scss/components/apps/ClusterNodes.scss"; +import "@src/scss/components/apps/KurlClusterManagement.scss"; import Icon from "../Icon"; -export class ClusterNodes extends Component { +export class KurlClusterManagement extends Component { state = { generating: false, command: "", @@ -287,7 +287,7 @@ export class ClusterNodes extends Component { ); } return ( -
+
@@ -298,7 +298,7 @@ export class ClusterNodes extends Component {
{kurl?.nodes && kurl?.nodes.map((node, i) => ( - { @@ -59,7 +59,7 @@ export default function NodeRow(props) { }; return ( -
+

@@ -71,7 +71,7 @@ export default function NodeRow(props) { )}

-
+

-
+

diff --git a/web/src/components/apps/NodeRow.test.js b/web/src/components/apps/KurlNodeRow.test.js similarity index 64% rename from web/src/components/apps/NodeRow.test.js rename to web/src/components/apps/KurlNodeRow.test.js index 7c045a60a2..415445998a 100644 --- a/web/src/components/apps/NodeRow.test.js +++ b/web/src/components/apps/KurlNodeRow.test.js @@ -1,3 +1,3 @@ -describe("NodeRow", () => { +describe("KurlNodeRow", () => { it.todo("upgrade to react 18 and add unit tests"); }); diff --git a/web/src/components/modals/AutomaticUpdatesModal.tsx b/web/src/components/modals/AutomaticUpdatesModal.tsx index 29b35fb02e..09ab6daef7 100644 --- a/web/src/components/modals/AutomaticUpdatesModal.tsx +++ b/web/src/components/modals/AutomaticUpdatesModal.tsx @@ -325,7 +325,8 @@ export default class AutomaticUpdatesModal extends React.Component<

Choose how frequently your application checks for updates. A - custom schedule can be defined with a cron expression. + custom schedule can be defined with a cron expression. Configured + automatic update checks use the local server time.