diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index 10ecb57534..40469c3027 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -1940,13 +1940,6 @@ jobs: exit $EXIT_CODE fi - # validate that preflight checks ran - JSON_PATH="jsonpath={.data['automated-install-slug-$APP_SLUG']}" - if [ "$(kubectl get cm kotsadm-tasks -n "$APP_SLUG" -o "$JSON_PATH" | grep -c pending_preflight)" != "1" ]; then - echo "Preflight checks did not run" - exit 1 - fi - COUNTER=1 while [ "$(./bin/kots get apps --namespace "$APP_SLUG" | awk 'NR>1{print $2}')" != "ready" ]; do ((COUNTER += 1)) diff --git a/Makefile b/Makefile index 355dc24dea..e8c70875d9 100644 --- a/Makefile +++ b/Makefile @@ -51,6 +51,8 @@ kots: capture-start-time kots-real report-metric .PHONY: kots-real kots-real: + mkdir -p web/dist + touch web/dist/README.md go build ${LDFLAGS} -o bin/kots $(BUILDFLAGS) github.com/replicatedhq/kots/cmd/kots .PHONY: fmt @@ -80,7 +82,7 @@ build: capture-start-time build-real report-metric .PHONY: build-real build-real: mkdir -p web/dist - touch web/dist/THIS_IS_OKTETO # we need this for go:embed, but it's not actually used in dev + touch web/dist/README.md go build ${LDFLAGS} ${GCFLAGS} -v -o bin/kotsadm $(BUILDFLAGS) ./cmd/kotsadm .PHONY: tidy @@ -112,21 +114,31 @@ debug-build: debug: debug-build LOG_LEVEL=$(LOG_LEVEL) dlv --listen=:2345 --headless=true --api-version=2 exec ./bin/kotsadm-debug api -.PHONY: build-ttl.sh -build-ttl.sh: kots build +.PHONY: web +web: source .image.env && ${MAKE} -C web build-kotsadm - docker build -f deploy/Dockerfile -t ttl.sh/${CURRENT_USER}/kotsadm:24h . + +.PHONY: build-ttl.sh +build-ttl.sh: export GOOS ?= linux +build-ttl.sh: export GOARCH ?= amd64 +build-ttl.sh: web kots build + docker build --platform $(GOOS)/$(GOARCH) -f deploy/Dockerfile -t ttl.sh/${CURRENT_USER}/kotsadm:24h . docker push ttl.sh/${CURRENT_USER}/kotsadm:24h .PHONY: all-ttl.sh +all-ttl.sh: export GOOS ?= linux +all-ttl.sh: export GOARCH ?= amd64 all-ttl.sh: build-ttl.sh - source .image.env && IMAGE=ttl.sh/${CURRENT_USER}/kotsadm-migrations:24h make -C migrations build_schema + source .image.env && \ + IMAGE=ttl.sh/${CURRENT_USER}/kotsadm-migrations:24h \ + DOCKER_BUILD_ARGS="--platform $(GOOS)/$(GOARCH)" \ + make -C migrations build_schema - docker pull kotsadm/minio:${MINIO_TAG} + docker pull --platform $(GOOS)/$(GOARCH)" kotsadm/minio:${MINIO_TAG} docker tag kotsadm/minio:${MINIO_TAG} ttl.sh/${CURRENT_USER}/minio:${MINIO_TAG} docker push ttl.sh/${CURRENT_USER}/minio:${MINIO_TAG} - docker pull kotsadm/rqlite:${RQLITE_TAG} + docker pull --platform $(GOOS)/$(GOARCH)" kotsadm/rqlite:${RQLITE_TAG} docker tag kotsadm/rqlite:${RQLITE_TAG} ttl.sh/${CURRENT_USER}/rqlite:${RQLITE_TAG} docker push ttl.sh/${CURRENT_USER}/rqlite:${RQLITE_TAG} diff --git a/cmd/kots/cli/admin-console-push-images.go b/cmd/kots/cli/admin-console-push-images.go index 90e8f31d9d..b584bb7822 100644 --- a/cmd/kots/cli/admin-console-push-images.go +++ b/cmd/kots/cli/admin-console-push-images.go @@ -115,12 +115,15 @@ func genAndCheckPushOptions(endpoint string, namespace string, log *logger.CLILo log.FinishSpinner() } + registryEndpoint, registryNamespace := splitEndpointAndNamespace(endpoint) + options := imagetypes.PushImagesOptions{ KotsadmTag: v.GetString("kotsadm-tag"), Registry: registrytypes.RegistryOptions{ - Endpoint: endpoint, - Username: username, - Password: password, + Endpoint: registryEndpoint, + Namespace: registryNamespace, + Username: username, + Password: password, }, ProgressWriter: os.Stdout, } diff --git a/cmd/kots/cli/airgap-update.go b/cmd/kots/cli/airgap-update.go new file mode 100644 index 0000000000..2d28e5bc7d --- /dev/null +++ b/cmd/kots/cli/airgap-update.go @@ -0,0 +1,252 @@ +package cli + +import ( + "bufio" + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + + "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/archives" + "github.com/replicatedhq/kots/pkg/auth" + registrytypes "github.com/replicatedhq/kots/pkg/docker/registry/types" + "github.com/replicatedhq/kots/pkg/image" + imagetypes "github.com/replicatedhq/kots/pkg/image/types" + "github.com/replicatedhq/kots/pkg/k8sutil" + "github.com/replicatedhq/kots/pkg/kotsutil" + "github.com/replicatedhq/kots/pkg/logger" + "github.com/replicatedhq/kots/pkg/tasks" + "github.com/replicatedhq/kots/pkg/upload" + "github.com/replicatedhq/kots/pkg/util" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func AirgapUpdateCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "airgap-update [appSlug]", + Short: "Process and upload an airgap update to the admin console", + Long: "", + SilenceUsage: true, + SilenceErrors: false, + Hidden: true, + PreRun: func(cmd *cobra.Command, args []string) { + viper.BindPFlags(cmd.Flags()) + }, + RunE: func(cmd *cobra.Command, args []string) error { + v := viper.GetViper() + + if len(args) == 0 { + cmd.Help() + os.Exit(1) + } + + appSlug := args[0] + log := logger.NewCLILogger(cmd.OutOrStdout()) + + airgapBundle := v.GetString("airgap-bundle") + if airgapBundle == "" { + return fmt.Errorf("--airgap-bundle is required") + } + + namespace, err := getNamespaceOrDefault(v.GetString("namespace")) + if err != nil { + return errors.Wrap(err, "failed to get namespace") + } + + clientset, err := k8sutil.GetClientset() + if err != nil { + return errors.Wrap(err, "failed to get clientset") + } + + registryConfig, err := getRegistryConfig(v, clientset, appSlug) + if err != nil { + return errors.Wrap(err, "failed to get registry config") + } + + pushOpts := imagetypes.PushImagesOptions{ + KotsadmTag: v.GetString("kotsadm-tag"), + Registry: registrytypes.RegistryOptions{ + Endpoint: registryConfig.OverrideRegistry, + Namespace: registryConfig.OverrideNamespace, + Username: registryConfig.Username, + Password: registryConfig.Password, + }, + ProgressWriter: getProgressWriter(v, log), + LogForUI: v.GetBool("from-api"), + } + + if _, err := os.Stat(airgapBundle); err == nil { + err = image.TagAndPushImagesFromBundle(airgapBundle, pushOpts) + if err != nil { + return errors.Wrap(err, "failed to push images") + } + } else { + return errors.Wrap(err, "failed to stat airgap bundle") + } + + updateFiles, err := getAirgapUpdateFiles(airgapBundle) + if err != nil { + return errors.Wrap(err, "failed to get airgap update files") + } + airgapUpdate, err := archives.FilterAirgapBundle(airgapBundle, updateFiles) + if err != nil { + return errors.Wrap(err, "failed to create filtered airgap bundle") + } + defer os.RemoveAll(airgapUpdate) + + var localPort int + if v.GetBool("from-api") { + localPort = 3000 + } else { + stopCh := make(chan struct{}) + defer close(stopCh) + + lp, errChan, err := upload.StartPortForward(namespace, stopCh, log) + if err != nil { + return err + } + localPort = lp + + go func() { + select { + case err := <-errChan: + if err != nil { + log.Error(err) + os.Exit(1) + } + case <-stopCh: + } + }() + } + + uploadEndpoint := fmt.Sprintf("http://localhost:%d/api/v1/app/%s/airgap/update", localPort, url.PathEscape(appSlug)) + + log.ActionWithSpinner("Uploading airgap update") + if err := uploadAirgapUpdate(airgapUpdate, uploadEndpoint, namespace); err != nil { + log.FinishSpinnerWithError() + return errors.Wrap(err, "failed to upload airgap update") + } + log.FinishSpinner() + + return nil + }, + } + + cmd.Flags().StringP("namespace", "n", "", "the namespace in which kots/kotsadm is installed") + cmd.Flags().String("airgap-bundle", "", "path to the application airgap bundle to upload") + + cmd.Flags().Bool("from-api", false, "whether the airgap update command was triggered by the API") + cmd.Flags().String("task-id", "", "the task ID to use for tracking progress") + cmd.Flags().MarkHidden("from-api") + cmd.Flags().MarkHidden("task-id") + + registryFlags(cmd.Flags()) + + return cmd +} + +func getProgressWriter(v *viper.Viper, log *logger.CLILogger) io.Writer { + if v.GetBool("from-api") { + pipeReader, pipeWriter := io.Pipe() + go func() { + scanner := bufio.NewScanner(pipeReader) + for scanner.Scan() { + if err := tasks.SetTaskStatus(v.GetString("task-id"), scanner.Text(), "running"); err != nil { + log.Error(err) + } + } + pipeReader.CloseWithError(scanner.Err()) + }() + return pipeWriter + } + return os.Stdout +} + +func getAirgapUpdateFiles(airgapBundle string) ([]string, error) { + airgap, err := kotsutil.FindAirgapMetaInBundle(airgapBundle) + if err != nil { + return nil, errors.Wrap(err, "failed to find airgap meta in bundle") + } + + if airgap.Spec.EmbeddedClusterArtifacts == nil { + return nil, errors.New("embedded cluster artifacts not found in airgap bundle") + } + + if airgap.Spec.EmbeddedClusterArtifacts.Metadata == "" { + return nil, errors.New("embedded cluster metadata not found in airgap bundle") + } + + if airgap.Spec.EmbeddedClusterArtifacts.AdditionalArtifacts == nil { + return nil, errors.New("embedded cluster additional artifacts not found in airgap bundle") + } + + files := []string{ + "airgap.yaml", + "app.tar.gz", + airgap.Spec.EmbeddedClusterArtifacts.Metadata, + airgap.Spec.EmbeddedClusterArtifacts.AdditionalArtifacts["kots"], + } + + return files, nil +} + +func uploadAirgapUpdate(airgapBundle string, uploadEndpoint string, namespace string) error { + buffer := bytes.NewBuffer(nil) + writer := multipart.NewWriter(buffer) + + part, err := writer.CreateFormFile("application.airgap", "application.airgap") + if err != nil { + return errors.Wrap(err, "failed to create form file") + } + + f, err := os.Open(airgapBundle) + if err != nil { + return errors.Wrap(err, "failed to open airgap bundle") + } + defer f.Close() + + if _, err := io.Copy(part, f); err != nil { + return errors.Wrap(err, "failed to copy airgap bundle to form file") + } + + err = writer.Close() + if err != nil { + return errors.Wrap(err, "failed to close writer") + } + + clientset, err := k8sutil.GetClientset() + if err != nil { + return errors.Wrap(err, "failed to get k8s clientset") + } + + authSlug, err := auth.GetOrCreateAuthSlug(clientset, namespace) + if err != nil { + return errors.Wrap(err, "failed to get auth slug") + } + + newReq, err := util.NewRequest("PUT", uploadEndpoint, buffer) + if err != nil { + return errors.Wrap(err, "failed to create request") + } + newReq.Header.Add("Content-Type", writer.FormDataContentType()) + newReq.Header.Add("Authorization", authSlug) + + resp, err := http.DefaultClient.Do(newReq) + if err != nil { + return errors.Wrap(err, "failed to make request") + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return errors.New("App not found") + } else if resp.StatusCode != 200 { + return errors.Errorf("Unexpected status code: %d", resp.StatusCode) + } + + return nil +} diff --git a/cmd/kots/cli/install.go b/cmd/kots/cli/install.go index 51488ecb0d..fb9d367fcf 100644 --- a/cmd/kots/cli/install.go +++ b/cmd/kots/cli/install.go @@ -37,8 +37,8 @@ import ( "github.com/replicatedhq/kots/pkg/print" "github.com/replicatedhq/kots/pkg/pull" "github.com/replicatedhq/kots/pkg/replicatedapp" - "github.com/replicatedhq/kots/pkg/store/kotsstore" storetypes "github.com/replicatedhq/kots/pkg/store/types" + "github.com/replicatedhq/kots/pkg/tasks" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/replicatedhq/troubleshoot/pkg/preflight" "github.com/spf13/cobra" @@ -648,7 +648,7 @@ func uploadAirgapArchive(deployOptions kotsadmtypes.DeployOptions, authSlug stri return false, errors.Wrap(err, "failed to create form from file") } - contents, err := archives.GetFileFromAirgap(filename, deployOptions.AirgapBundle) + contents, err := archives.GetFileContentFromTGZArchive(filename, deployOptions.AirgapBundle) if err != nil { return false, errors.Wrap(err, "failed to get file from airgap") } @@ -887,7 +887,7 @@ func ValidateAutomatedInstall(deployOptions kotsadmtypes.DeployOptions, authSlug return "", errors.New("timeout waiting for automated install. Use the --wait-duration flag to increase timeout.") } -func getAutomatedInstallStatus(url string, authSlug string) (*kotsstore.TaskStatus, error) { +func getAutomatedInstallStatus(url string, authSlug string) (*tasks.TaskStatus, error) { newReq, err := http.NewRequest("GET", url, nil) if err != nil { return nil, errors.Wrap(err, "failed to create request") @@ -910,7 +910,7 @@ func getAutomatedInstallStatus(url string, authSlug string) (*kotsstore.TaskStat return nil, errors.Wrap(err, "failed to read response body") } - taskStatus := kotsstore.TaskStatus{} + taskStatus := tasks.TaskStatus{} if err := json.Unmarshal(b, &taskStatus); err != nil { return nil, errors.Wrap(err, "failed to unmarshal task status") } diff --git a/cmd/kots/cli/install_test.go b/cmd/kots/cli/install_test.go index 4b426bf07f..59f641bb5a 100644 --- a/cmd/kots/cli/install_test.go +++ b/cmd/kots/cli/install_test.go @@ -14,7 +14,7 @@ import ( "github.com/replicatedhq/kots/pkg/handlers" kotsadmtypes "github.com/replicatedhq/kots/pkg/kotsadm/types" preflighttypes "github.com/replicatedhq/kots/pkg/preflight/types" - "github.com/replicatedhq/kots/pkg/store/kotsstore" + "github.com/replicatedhq/kots/pkg/tasks" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/replicatedhq/troubleshoot/pkg/preflight" ) @@ -588,7 +588,7 @@ func createPreflightResponse(isFail bool, isWarn bool, hasPassingStrict bool, pe } func createTaskStatus(status string, message string) ([]byte, error) { - return json.Marshal(kotsstore.TaskStatus{ + return json.Marshal(tasks.TaskStatus{ Message: message, Status: status, }) diff --git a/cmd/kots/cli/root.go b/cmd/kots/cli/root.go index a7b9c9941f..8035c5220a 100644 --- a/cmd/kots/cli/root.go +++ b/cmd/kots/cli/root.go @@ -55,6 +55,8 @@ func RootCmd() *cobra.Command { cmd.AddCommand(CompletionCmd()) cmd.AddCommand(DockerRegistryCmd()) cmd.AddCommand(EnableHACmd()) + cmd.AddCommand(UpgradeServiceCmd()) + cmd.AddCommand(AirgapUpdateCmd()) viper.BindPFlags(cmd.Flags()) diff --git a/cmd/kots/cli/upgrade-service.go b/cmd/kots/cli/upgrade-service.go new file mode 100644 index 0000000000..f0ea172f65 --- /dev/null +++ b/cmd/kots/cli/upgrade-service.go @@ -0,0 +1,78 @@ +package cli + +import ( + "fmt" + "io" + "os" + + "github.com/replicatedhq/kots/pkg/upgradeservice" + "github.com/replicatedhq/kots/pkg/upgradeservice/types" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "gopkg.in/yaml.v3" +) + +func UpgradeServiceCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "upgrade-service", + Short: "KOTS Upgrade Service", + Hidden: true, + } + + cmd.AddCommand(UpgradeServiceStartCmd()) + + return cmd +} + +func UpgradeServiceStartCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "start [params-file]", + Short: "Starts a KOTS upgrade service using the provided params file", + Long: ``, + SilenceUsage: true, + SilenceErrors: false, + PreRun: func(cmd *cobra.Command, args []string) { + viper.BindPFlags(cmd.Flags()) + os.Setenv("IS_UPGRADE_SERVICE", "true") + }, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + cmd.Help() + os.Exit(1) + } + + paramsYAML, err := readUpgradeServiceParams(args[0]) + if err != nil { + return fmt.Errorf("failed to read config file: %v", err) + } + + var params types.UpgradeServiceParams + if err := yaml.Unmarshal(paramsYAML, ¶ms); err != nil { + return fmt.Errorf("failed to unmarshal config file: %v", err) + } + + if err := upgradeservice.Serve(params); err != nil { + return err + } + + return nil + }, + } + + return cmd +} + +func readUpgradeServiceParams(path string) ([]byte, error) { + if path == "-" { + b, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, fmt.Errorf("read stdin: %w", err) + } + return b, nil + } + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + return b, nil +} diff --git a/cmd/kots/cli/util.go b/cmd/kots/cli/util.go index 3eda9b2990..4203831fcf 100644 --- a/cmd/kots/cli/util.go +++ b/cmd/kots/cli/util.go @@ -40,3 +40,14 @@ func getHostFromEndpoint(endpoint string) (string, error) { return parsed.Host, nil } + +func splitEndpointAndNamespace(endpoint string) (string, string) { + registryEndpoint := endpoint + registryNamespace := "" + parts := strings.Split(endpoint, "/") + if len(parts) > 1 { + registryEndpoint = parts[0] + registryNamespace = strings.Join(parts[1:], "/") + } + return registryEndpoint, registryNamespace +} diff --git a/cmd/kotsadm/cli/api.go b/cmd/kotsadm/cli/api.go index 0cbd9831bc..8d1900a6b7 100644 --- a/cmd/kotsadm/cli/api.go +++ b/cmd/kotsadm/cli/api.go @@ -32,7 +32,6 @@ func APICmd() *cobra.Command { params := apiserver.APIServerParams{ Version: buildversion.Version(), - RqliteURI: os.Getenv("RQLITE_URI"), AutocreateClusterToken: os.Getenv("AUTO_CREATE_CLUSTER_TOKEN"), } diff --git a/cmd/kotsadm/cli/migrate.go b/cmd/kotsadm/cli/migrate.go index f22feaeeb5..6d0f30c27b 100644 --- a/cmd/kotsadm/cli/migrate.go +++ b/cmd/kotsadm/cli/migrate.go @@ -5,7 +5,6 @@ import ( "github.com/pkg/errors" "github.com/replicatedhq/kots/pkg/filestore" - "github.com/replicatedhq/kots/pkg/persistence" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -54,9 +53,6 @@ func MigrateS3ToRqliteCmd() *cobra.Command { return errors.New("S3_SECRET_ACCESS_KEY is not set") } - // Initialize the rqlite DB - persistence.InitDB(os.Getenv("RQLITE_URI")) - // Migrate from S3 to rqlite if err := filestore.MigrateFromS3ToRqlite(cmd.Context()); err != nil { return err @@ -90,9 +86,6 @@ func MigratePVCToRqliteCmd() *cobra.Command { return errors.Wrap(err, "failed to stat archives dir") } - // Initialize the rqlite DB - persistence.InitDB(os.Getenv("RQLITE_URI")) - // Migrate from PVC to rqlite if err := filestore.MigrateFromPVCToRqlite(cmd.Context()); err != nil { return err diff --git a/go.mod b/go.mod index 807e554d91..0d46fa6b16 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/ghodss/yaml v1.0.0 github.com/go-git/go-git/v5 v5.12.0 github.com/go-logfmt/logfmt v0.6.0 + github.com/gofrs/flock v0.9.0 github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang/mock v1.6.0 github.com/google/go-github/v39 v39.2.0 diff --git a/go.sum b/go.sum index 80ac1b4dfa..5c227fe18b 100644 --- a/go.sum +++ b/go.sum @@ -685,6 +685,8 @@ github.com/gocql/gocql v1.6.0/go.mod h1:3gM2c4D3AnkISwBxGnMMsS8Oy4y2lhbPRsH4xnJr github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofrs/flock v0.9.0 h1:QqEH0zKHPdEyY4YbJLleD9Il4ft7h6hn3gECO6Ss4rQ= +github.com/gofrs/flock v0.9.0/go.mod h1:O+L78Axre/Bc0Ya3RlNiGP+Rt0tFHWjtHTQ+B2uPZw8= github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= diff --git a/hack/dev/skaffold.Dockerfile b/hack/dev/skaffold.Dockerfile index 32ffe776e3..4f69c3e6f6 100644 --- a/hack/dev/skaffold.Dockerfile +++ b/hack/dev/skaffold.Dockerfile @@ -2,7 +2,6 @@ FROM kotsadm:cache AS builder ENV PROJECTPATH=/go/src/github.com/replicatedhq/kots WORKDIR $PROJECTPATH -RUN mkdir -p web/dist && touch web/dist/README.md COPY Makefile ./ COPY Makefile.build.mk ./ COPY go.mod go.sum ./ diff --git a/hack/dev/skaffoldcache.Dockerfile b/hack/dev/skaffoldcache.Dockerfile index 70f7d7efc7..f500b80bbd 100644 --- a/hack/dev/skaffoldcache.Dockerfile +++ b/hack/dev/skaffoldcache.Dockerfile @@ -4,7 +4,6 @@ RUN go install github.com/go-delve/delve/cmd/dlv@v1.7.2 ENV PROJECTPATH=/go/src/github.com/replicatedhq/kots WORKDIR $PROJECTPATH -RUN mkdir -p web/dist && touch web/dist/README.md COPY Makefile ./ COPY Makefile.build.mk ./ COPY go.mod go.sum ./ diff --git a/kurl_proxy/Makefile b/kurl_proxy/Makefile index adf020ddca..8303eac60c 100644 --- a/kurl_proxy/Makefile +++ b/kurl_proxy/Makefile @@ -15,6 +15,8 @@ up: skaffold dev -f skaffold.yaml .PHONY: build-ttl.sh +build-ttl.sh: export GOOS ?= linux +build-ttl.sh: export GOARCH ?= amd64 build-ttl.sh: build - docker build --pull -f deploy/Dockerfile -t ttl.sh/${CURRENT_USER}/kurl-proxy:24h . + docker build --platform $(GOOS)/$(GOARCH) --pull -f deploy/Dockerfile -t ttl.sh/${CURRENT_USER}/kurl-proxy:24h . docker push ttl.sh/${CURRENT_USER}/kurl-proxy:24h diff --git a/kurl_proxy/cmd/main.go b/kurl_proxy/cmd/main.go index 5efdc6629c..21f41391f2 100644 --- a/kurl_proxy/cmd/main.go +++ b/kurl_proxy/cmd/main.go @@ -472,8 +472,8 @@ func getHttpsServer(upstream, dexUpstream *url.URL, tlsSecretName string, secret // CSPMiddleware adds Content-Security-Policy and X-Frame-Options headers to the response. func CSPMiddleware(c *gin.Context) { - c.Writer.Header().Set("Content-Security-Policy", "frame-ancestors 'none';") - c.Writer.Header().Set("X-Frame-Options", "DENY") + c.Writer.Header().Set("Content-Security-Policy", "frame-ancestors 'self';") + c.Writer.Header().Set("X-Frame-Options", "SAMEORIGIN") c.Next() } diff --git a/kurl_proxy/cmd/main_test.go b/kurl_proxy/cmd/main_test.go index 1ce0406526..83dd9a91a7 100644 --- a/kurl_proxy/cmd/main_test.go +++ b/kurl_proxy/cmd/main_test.go @@ -181,8 +181,8 @@ func Test_httpServerCSPHeaders(t *testing.T) { httpServer: getHttpServer("some-fingerprint", true, tmpDir), path: "/assets/index.html", wantHeaders: map[string]string{ - "Content-Security-Policy": "frame-ancestors 'none';", - "X-Frame-Options": "DENY", + "Content-Security-Policy": "frame-ancestors 'self';", + "X-Frame-Options": "SAMEORIGIN", }, }, { @@ -191,8 +191,8 @@ func Test_httpServerCSPHeaders(t *testing.T) { isHttps: true, path: "/tls/assets/index.html", wantHeaders: map[string]string{ - "Content-Security-Policy": "frame-ancestors 'none';", - "X-Frame-Options": "DENY", + "Content-Security-Policy": "frame-ancestors 'self';", + "X-Frame-Options": "SAMEORIGIN", }, }, } @@ -275,15 +275,15 @@ func Test_generateDefaultCertSecret(t *testing.T) { func Test_generateCertHostnames(t *testing.T) { tests := []struct { - name string + name string namespace string hostname string - altNames []string + altNames []string }{ { - name: "with no namespace", - hostname: "kotsadm.default.svc.cluster.local", - altNames : []string{ + name: "with no namespace", + hostname: "kotsadm.default.svc.cluster.local", + altNames: []string{ "kotsadm", "kotsadm.default", "kotsadm.default.svc", @@ -292,10 +292,10 @@ func Test_generateCertHostnames(t *testing.T) { }, }, { - name: "with some other namespace", + name: "with some other namespace", namespace: "somecluster", hostname: "kotsadm.default.svc.cluster.local", - altNames : []string{ + altNames: []string{ "kotsadm", "kotsadm.default", "kotsadm.default.svc", diff --git a/migrations/Makefile b/migrations/Makefile index a98a55d894..c242899b0c 100644 --- a/migrations/Makefile +++ b/migrations/Makefile @@ -1,6 +1,7 @@ SHELL:=/bin/bash SCHEMAHERO_TAG ?= 0.17.9 +DOCKER_BUILD_ARGS ?= build_schema: - docker build --pull --build-arg SCHEMAHERO_TAG=${SCHEMAHERO_TAG} -f deploy/Dockerfile -t ${IMAGE} . + docker build --pull --build-arg SCHEMAHERO_TAG=${SCHEMAHERO_TAG} ${DOCKER_BUILD_ARGS} -f deploy/Dockerfile -t ${IMAGE} . docker push ${IMAGE} diff --git a/migrations/tables/api_task_status.yaml b/migrations/tables/api_task_status.yaml index de2fdc49f7..2915005dcb 100644 --- a/migrations/tables/api_task_status.yaml +++ b/migrations/tables/api_task_status.yaml @@ -1,4 +1,3 @@ -## no longer used, must keep for migrations to complete apiVersion: schemas.schemahero.io/v1alpha4 kind: Table metadata: diff --git a/migrations/tables/embeded_cluster_status.yaml b/migrations/tables/embeded_cluster_status.yaml deleted file mode 100644 index 790f860ed1..0000000000 --- a/migrations/tables/embeded_cluster_status.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: schemas.schemahero.io/v1alpha4 -kind: Table -metadata: - name: embedded-cluster-status -spec: - name: embedded_cluster_status - requires: [] - schema: - rqlite: - strict: true - primaryKey: - - updated_at - columns: - - name: updated_at - type: integer - - name: status - type: text diff --git a/migrations/tables/instance-report.yaml b/migrations/tables/instance-report.yaml deleted file mode 100644 index dae94afd31..0000000000 --- a/migrations/tables/instance-report.yaml +++ /dev/null @@ -1,66 +0,0 @@ -apiVersion: schemas.schemahero.io/v1alpha4 -kind: Table -metadata: - name: instance-report -spec: - name: instance_report - requires: [] - schema: - rqlite: - strict: true - primaryKey: - - created_at - columns: - - name: created_at - type: integer - - name: license_id - type: text - - name: instance_id - type: text - - name: cluster_id - type: text - - name: app_status - type: text - - name: is_kurl - type: integer - - name: kurl_node_count_total - type: integer - - name: kurl_node_count_ready - type: integer - - name: k8s_version - type: text - - name: kots_version - type: text - - name: kots_install_id - type: text - - name: kurl_install_id - type: text - - name: embedded_cluster_id - type: text - - name: embedded_cluster_version - type: text - - name: is_gitops_enabled - type: integer - - name: gitops_provider - type: text -# downstream stuff - - name: downstream_channel_id - type: text - - name: downstream_channel_sequence - type: integer - - name: downstream_channel_name - type: text - - name: downstream_sequence - type: integer - - name: downstream_source - type: text - - name: install_status - type: text - - name: preflight_state - type: text - - name: skip_preflights - type: integer - - name: repl_helm_installs - type: integer - - name: native_helm_installs - type: integer \ No newline at end of file diff --git a/migrations/tables/preflight-report.yaml b/migrations/tables/preflight-report.yaml deleted file mode 100644 index 775772d716..0000000000 --- a/migrations/tables/preflight-report.yaml +++ /dev/null @@ -1,35 +0,0 @@ -apiVersion: schemas.schemahero.io/v1alpha4 -kind: Table -metadata: - name: preflight-report -spec: - name: preflight_report - requires: [] - schema: - rqlite: - strict: true - primaryKey: - - created_at - columns: - - name: created_at - type: integer - - name: license_id - type: text - - name: instance_id - type: text - - name: cluster_id - type: text - - name: sequence - type: integer - - name: skip_preflights - type: integer - - name: install_status - type: text - - name: is_cli - type: integer - - name: preflight_status - type: text - - name: app_status - type: text - - name: kots_version - type: text diff --git a/pkg/airgap/airgap.go b/pkg/airgap/airgap.go index 13262947bc..d0aa6f9135 100644 --- a/pkg/airgap/airgap.go +++ b/pkg/airgap/airgap.go @@ -1,9 +1,7 @@ package airgap import ( - "archive/tar" "bufio" - "compress/gzip" "fmt" "io" "io/ioutil" @@ -28,6 +26,7 @@ import ( storetypes "github.com/replicatedhq/kots/pkg/store/types" "github.com/replicatedhq/kots/pkg/supportbundle" supportbundletypes "github.com/replicatedhq/kots/pkg/supportbundle/types" + "github.com/replicatedhq/kots/pkg/tasks" "github.com/replicatedhq/kots/pkg/util" "github.com/replicatedhq/kots/pkg/version" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" @@ -54,7 +53,7 @@ type CreateAirgapAppOpts struct { // will also have a version func CreateAppFromAirgap(opts CreateAirgapAppOpts) (finalError error) { taskID := fmt.Sprintf("airgap-install-slug-%s", opts.PendingApp.Slug) - if err := store.GetStore().SetTaskStatus(taskID, "Processing package...", "running"); err != nil { + if err := tasks.SetTaskStatus(taskID, "Processing package...", "running"); err != nil { return errors.Wrap(err, "failed to set task status") } @@ -63,8 +62,8 @@ func CreateAppFromAirgap(opts CreateAirgapAppOpts) (finalError error) { go func() { for { select { - case <-time.After(time.Second): - if err := store.GetStore().UpdateTaskStatusTimestamp(taskID); err != nil { + case <-time.After(time.Second * 2): + if err := tasks.UpdateTaskStatusTimestamp(taskID); err != nil { logger.Error(errors.Wrapf(err, "failed to update task %s", taskID)) } case <-finishedCh: @@ -75,14 +74,14 @@ func CreateAppFromAirgap(opts CreateAirgapAppOpts) (finalError error) { defer func() { if finalError == nil { - if err := store.GetStore().ClearTaskStatus(taskID); err != nil { + if err := tasks.ClearTaskStatus(taskID); err != nil { logger.Error(errors.Wrap(err, "failed to clear install task status")) } if err := store.GetStore().SetAppInstallState(opts.PendingApp.ID, "installed"); err != nil { logger.Error(errors.Wrap(err, "failed to set app status to installed")) } } else { - if err := store.GetStore().SetTaskStatus(taskID, finalError.Error(), "failed"); err != nil { + if err := tasks.SetTaskStatus(taskID, finalError.Error(), "failed"); err != nil { logger.Error(errors.Wrap(err, "failed to set error on install task status")) } if err := store.GetStore().SetAppInstallState(opts.PendingApp.ID, "airgap_upload_error"); err != nil { @@ -96,14 +95,14 @@ func CreateAppFromAirgap(opts CreateAirgapAppOpts) (finalError error) { } // Extract it - if err := store.GetStore().SetTaskStatus(taskID, "Extracting files...", "running"); err != nil { + if err := tasks.SetTaskStatus(taskID, "Extracting files...", "running"); err != nil { return errors.Wrap(err, "failed to set task status") } archiveDir := opts.AirgapRootDir if opts.AirgapBundle != "" { // on the api side, headless intalls don't have the airgap file - dir, err := extractAppMetaFromAirgapBundle(opts.AirgapBundle) + dir, err := archives.ExtractAppMetaFromAirgapBundle(opts.AirgapBundle) if err != nil { return errors.Wrap(err, "failed to extract archive") } @@ -130,7 +129,7 @@ func CreateAppFromAirgap(opts CreateAirgapAppOpts) (finalError error) { } defer os.RemoveAll(tmpRoot) - if err := store.GetStore().SetTaskStatus(taskID, "Reading license data...", "running"); err != nil { + if err := tasks.SetTaskStatus(taskID, "Reading license data...", "running"); err != nil { return errors.Wrap(err, "failed to set task status") } @@ -154,7 +153,7 @@ func CreateAppFromAirgap(opts CreateAirgapAppOpts) (finalError error) { go func() { scanner := bufio.NewScanner(pipeReader) for scanner.Scan() { - if err := store.GetStore().SetTaskStatus(taskID, scanner.Text(), "running"); err != nil { + if err := tasks.SetTaskStatus(taskID, scanner.Text(), "running"); err != nil { logger.Error(errors.Wrapf(err, "failed to set status for task %s", taskID)) } } @@ -257,7 +256,7 @@ func CreateAppFromAirgap(opts CreateAirgapAppOpts) (finalError error) { return errors.Wrap(err, "failed to set app is airgap the second time") } - newSequence, err := store.GetStore().CreateAppVersion(a.ID, nil, tmpRoot, "Airgap Install", opts.SkipPreflights, &version.DownstreamGitOps{}, render.Renderer{}) + newSequence, err := store.GetStore().CreateAppVersion(a.ID, nil, tmpRoot, "Airgap Install", opts.SkipPreflights, render.Renderer{}) if err != nil { return errors.Wrap(err, "failed to create new version") } @@ -378,65 +377,3 @@ func extractAppRelease(workspace string, airgapDir string) (string, error) { return destDir, nil } - -func extractAppMetaFromAirgapBundle(airgapBundle string) (string, error) { - destDir, err := ioutil.TempDir("", "kotsadm-airgap-meta-") - if err != nil { - return "", errors.Wrap(err, "failed to create temp dir") - } - - fileReader, err := os.Open(airgapBundle) - if err != nil { - return "", errors.Wrap(err, "failed to open file") - } - defer fileReader.Close() - - gzipReader, err := gzip.NewReader(fileReader) - if err != nil { - return "", errors.Wrap(err, "failed to get new gzip reader") - } - defer gzipReader.Close() - - tarReader := tar.NewReader(gzipReader) - for { - header, err := tarReader.Next() - if err == io.EOF { - break - } - if err != nil { - return "", errors.Wrap(err, "failed to get read archive") - } - - // First items in airgap archive are metadata files. - // As soon as we see the first directory, we are hitting images. - if header.Name == "." { - continue - } - if header.Typeflag != tar.TypeReg { - break - } - - err = func() error { - fileName := filepath.Join(destDir, header.Name) - - fileWriter, err := os.Create(fileName) - if err != nil { - return errors.Wrapf(err, "failed to create file %q", header.Name) - } - - defer fileWriter.Close() - - _, err = io.Copy(fileWriter, tarReader) - if err != nil { - return errors.Wrapf(err, "failed to write file %q", header.Name) - } - - return nil - }() - if err != nil { - return "", err - } - } - - return destDir, nil -} diff --git a/pkg/airgap/update.go b/pkg/airgap/update.go index b143bc8860..35986a9863 100644 --- a/pkg/airgap/update.go +++ b/pkg/airgap/update.go @@ -5,13 +5,13 @@ import ( "fmt" "io" "os" + "os/exec" "path/filepath" - "strings" "github.com/blang/semver" "github.com/pkg/errors" - downstreamtypes "github.com/replicatedhq/kots/pkg/api/downstream/types" apptypes "github.com/replicatedhq/kots/pkg/app/types" + "github.com/replicatedhq/kots/pkg/archives" "github.com/replicatedhq/kots/pkg/cursor" identity "github.com/replicatedhq/kots/pkg/kotsadmidentity" "github.com/replicatedhq/kots/pkg/kotsutil" @@ -22,25 +22,52 @@ import ( "github.com/replicatedhq/kots/pkg/store" storetypes "github.com/replicatedhq/kots/pkg/store/types" "github.com/replicatedhq/kots/pkg/tasks" + "github.com/replicatedhq/kots/pkg/update" "github.com/replicatedhq/kots/pkg/util" "github.com/replicatedhq/kots/pkg/version" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" ) +func UpdateAppFromECBundle(appSlug string, airgapBundlePath string) (finalError error) { + finishedChan := make(chan error) + defer close(finishedChan) + + taskID := "update-download" + tasks.StartTaskMonitor(taskID, finishedChan) + defer func() { + finishedChan <- finalError + }() + + kotsBin, err := kotsutil.GetKOTSBinFromAirgapBundle(airgapBundlePath) + if err != nil { + return errors.Wrap(err, "failed to get kots binary from airgap bundle") + } + defer os.Remove(kotsBin) + + cmd := exec.Command(kotsBin, "airgap-update", appSlug, "-n", util.PodNamespace, "--airgap-bundle", airgapBundlePath, "--from-api", "--task-id", taskID) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return errors.Wrap(err, "failed to run airgap update") + } + + return nil +} + func UpdateAppFromAirgap(a *apptypes.App, airgapBundlePath string, deploy bool, skipPreflights bool, skipCompatibilityCheck bool) (finalError error) { finishedChan := make(chan error) defer close(finishedChan) - tasks.StartUpdateTaskMonitor("update-download", finishedChan) + tasks.StartTaskMonitor("update-download", finishedChan) defer func() { finishedChan <- finalError }() - if err := store.GetStore().SetTaskStatus("update-download", "Extracting files...", "running"); err != nil { + if err := tasks.SetTaskStatus("update-download", "Extracting files...", "running"); err != nil { return errors.Wrap(err, "failed to set task status") } - airgapRoot, err := extractAppMetaFromAirgapBundle(airgapBundlePath) + airgapRoot, err := archives.ExtractAppMetaFromAirgapBundle(airgapBundlePath) if err != nil { return errors.Wrap(err, "failed to extract archive") } @@ -55,7 +82,7 @@ func UpdateAppFromAirgap(a *apptypes.App, airgapBundlePath string, deploy bool, } func UpdateAppFromPath(a *apptypes.App, airgapRoot string, airgapBundlePath string, deploy bool, skipPreflights bool, skipCompatibilityCheck bool) error { - if err := store.GetStore().SetTaskStatus("update-download", "Processing package...", "running"); err != nil { + if err := tasks.SetTaskStatus("update-download", "Processing package...", "running"); err != nil { return errors.Wrap(err, "failed to set tasks status") } @@ -69,15 +96,14 @@ func UpdateAppFromPath(a *apptypes.App, airgapRoot string, airgapBundlePath stri return errors.Wrap(err, "failed to find airgap meta") } - missingPrereqs, err := GetMissingRequiredVersions(a, airgap) + deployable, nonDeployableCause, err := update.IsAirgapUpdateDeployable(a, airgap) if err != nil { - return errors.Wrapf(err, "failed to check required versions") + return errors.Wrapf(err, "failed to check if airgap update is deployable") } - - if len(missingPrereqs) > 0 { + if !deployable { return util.ActionableError{ NoRetry: true, - Message: fmt.Sprintf("This airgap bundle cannot be uploaded because versions %s are required and must be uploaded first.", strings.Join(missingPrereqs, ", ")), + Message: nonDeployableCause, } } @@ -97,7 +123,7 @@ func UpdateAppFromPath(a *apptypes.App, airgapRoot string, airgapBundlePath stri return err } - if err := store.GetStore().SetTaskStatus("update-download", "Processing app package...", "running"); err != nil { + if err := tasks.SetTaskStatus("update-download", "Processing app package...", "running"); err != nil { return errors.Wrap(err, "failed to set task status") } @@ -113,7 +139,7 @@ func UpdateAppFromPath(a *apptypes.App, airgapRoot string, airgapBundlePath stri downstreamNames = append(downstreamNames, d.Name) } - if err := store.GetStore().SetTaskStatus("update-download", "Creating app version...", "running"); err != nil { + if err := tasks.SetTaskStatus("update-download", "Creating app version...", "running"); err != nil { return errors.Wrap(err, "failed to set task status") } @@ -126,7 +152,7 @@ func UpdateAppFromPath(a *apptypes.App, airgapRoot string, airgapBundlePath stri go func() { scanner := bufio.NewScanner(pipeReader) for scanner.Scan() { - if err := store.GetStore().SetTaskStatus("update-download", scanner.Text(), "running"); err != nil { + if err := tasks.SetTaskStatus("update-download", scanner.Text(), "running"); err != nil { logger.Error(errors.Wrap(err, "failed to update download status")) } } @@ -202,7 +228,7 @@ func UpdateAppFromPath(a *apptypes.App, airgapRoot string, airgapBundlePath stri } // Create the app in the db - newSequence, err := store.GetStore().CreateAppVersion(a.ID, &baseSequence, archiveDir, "Airgap Update", skipPreflights, &version.DownstreamGitOps{}, render.Renderer{}) + newSequence, err := store.GetStore().CreateAppVersion(a.ID, &baseSequence, archiveDir, "Airgap Update", skipPreflights, render.Renderer{}) if err != nil { return errors.Wrap(err, "failed to create new version") } @@ -311,66 +337,3 @@ func canInstall(beforeKotsKinds *kotsutil.KotsKinds, afterKotsKinds *kotsutil.Ko return nil } - -func GetMissingRequiredVersions(app *apptypes.App, airgap *kotsv1beta1.Airgap) ([]string, error) { - appVersions, err := store.GetStore().FindDownstreamVersions(app.ID, true) - if err != nil { - return nil, errors.Wrap(err, "failed to get downstream versions") - } - - license, err := kotsutil.LoadLicenseFromBytes([]byte(app.License)) - if err != nil { - return nil, errors.Wrap(err, "failed to load license") - } - - return getMissingRequiredVersions(airgap, license, appVersions.AllVersions, app.ChannelChanged) -} - -func getMissingRequiredVersions(airgap *kotsv1beta1.Airgap, license *kotsv1beta1.License, installedVersions []*downstreamtypes.DownstreamVersion, channelChanged bool) ([]string, error) { - missingVersions := make([]string, 0) - // If no versions are installed, we can consider this an initial install. - // If the channel changed, we can consider this an initial install. - if len(installedVersions) == 0 || channelChanged { - return missingVersions, nil - } - - for _, requiredRelease := range airgap.Spec.RequiredReleases { - laterReleaseInstalled := false - for _, appVersion := range installedVersions { - requiredSemver, requiredSemverErr := semver.ParseTolerant(requiredRelease.VersionLabel) - - // semvers can be compared across channels - // if a semmver is missing, fallback to comparing the cursor but only if channel is the same - if license.Spec.IsSemverRequired && appVersion.Semver != nil && requiredSemverErr == nil { - if requiredSemver.LE(*appVersion.Semver) { - laterReleaseInstalled = true - break - } - } else { - // cursors can only be compared on the same channel - if appVersion.ChannelID != airgap.Spec.ChannelID { - continue - } - if appVersion.Cursor == nil { - return nil, errors.Errorf("cursor required but version %s does not have cursor", appVersion.UpdateCursor) - } - requiredCursor, err := cursor.NewCursor(requiredRelease.UpdateCursor) - if err != nil { - return nil, errors.Wrapf(err, "failed to parse required update cursor %q", requiredRelease.UpdateCursor) - } - if requiredCursor.Before(*appVersion.Cursor) || requiredCursor.Equal(*appVersion.Cursor) { - laterReleaseInstalled = true - break - } - } - } - - if !laterReleaseInstalled { - missingVersions = append([]string{requiredRelease.VersionLabel}, missingVersions...) - } else { - break - } - } - - return missingVersions, nil -} diff --git a/pkg/airgap/update_test.go b/pkg/airgap/update_test.go index 40180a907b..0d3114823a 100644 --- a/pkg/airgap/update_test.go +++ b/pkg/airgap/update_test.go @@ -3,210 +3,11 @@ package airgap import ( "testing" - "github.com/blang/semver" - downstreamtypes "github.com/replicatedhq/kots/pkg/api/downstream/types" - "github.com/replicatedhq/kots/pkg/cursor" "github.com/replicatedhq/kots/pkg/kotsutil" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/stretchr/testify/require" ) -func Test_getMissingRequiredVersions(t *testing.T) { - channelID := "channel-id" - tests := []struct { - name string - airgap *kotsv1beta1.Airgap - license *kotsv1beta1.License - installedVersions []*downstreamtypes.DownstreamVersion - channelChanged bool - wantSemver []string - wantNoSemver []string - }{ - { - name: "nothing is installed yet", - airgap: &kotsv1beta1.Airgap{ - Spec: kotsv1beta1.AirgapSpec{ - ChannelID: channelID, - RequiredReleases: []kotsv1beta1.AirgapReleaseMeta{ - { - VersionLabel: "0.1.123", - UpdateCursor: "123", - }, - }, - }, - }, - license: &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{}, - }, - installedVersions: []*downstreamtypes.DownstreamVersion{}, - wantNoSemver: []string{}, - wantSemver: []string{}, - }, - { - name: "latest satisfies all prerequsites", - airgap: &kotsv1beta1.Airgap{ - Spec: kotsv1beta1.AirgapSpec{ - ChannelID: channelID, - RequiredReleases: []kotsv1beta1.AirgapReleaseMeta{ - { - VersionLabel: "0.1.123", - UpdateCursor: "123", - }, - { - VersionLabel: "0.1.120", - UpdateCursor: "120", - }, - { - VersionLabel: "0.1.115", - UpdateCursor: "115", - }, - }, - }, - }, - license: &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{}, - }, - installedVersions: []*downstreamtypes.DownstreamVersion{ - { - ChannelID: channelID, - VersionLabel: "0.1.124", - UpdateCursor: "124", - }, - }, - wantNoSemver: []string{}, - wantSemver: []string{}, - }, - { - name: "need some prerequsites", - airgap: &kotsv1beta1.Airgap{ - Spec: kotsv1beta1.AirgapSpec{ - ChannelID: channelID, - RequiredReleases: []kotsv1beta1.AirgapReleaseMeta{ - { - VersionLabel: "0.1.123", - UpdateCursor: "123", - }, - { - VersionLabel: "0.1.120", - UpdateCursor: "120", - }, - { - VersionLabel: "0.1.115", - UpdateCursor: "115", - }, - }, - }, - }, - license: &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{}, - }, - installedVersions: []*downstreamtypes.DownstreamVersion{ - { - ChannelID: channelID, - VersionLabel: "0.1.117", - UpdateCursor: "117", - }, - }, - wantNoSemver: []string{"0.1.120", "0.1.123"}, - wantSemver: []string{"0.1.120", "0.1.123"}, - }, - { - name: "need all prerequsites", - airgap: &kotsv1beta1.Airgap{ - Spec: kotsv1beta1.AirgapSpec{ - ChannelID: channelID, - RequiredReleases: []kotsv1beta1.AirgapReleaseMeta{ - { - VersionLabel: "0.1.123", - UpdateCursor: "123", - }, - { - VersionLabel: "0.1.120", - UpdateCursor: "120", - }, - { - VersionLabel: "0.1.115", - UpdateCursor: "115", - }, - }, - }, - }, - license: &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{}, - }, - installedVersions: []*downstreamtypes.DownstreamVersion{ - { - ChannelID: channelID, - VersionLabel: "0.1.113", - UpdateCursor: "113", - }, - }, - wantNoSemver: []string{"0.1.115", "0.1.120", "0.1.123"}, - wantSemver: []string{"0.1.115", "0.1.120", "0.1.123"}, - }, - { - name: "check across multiple channels", - airgap: &kotsv1beta1.Airgap{ - Spec: kotsv1beta1.AirgapSpec{ - ChannelID: channelID, - RequiredReleases: []kotsv1beta1.AirgapReleaseMeta{ - { - VersionLabel: "0.1.123", - UpdateCursor: "123", - }, - { - VersionLabel: "0.1.120", - UpdateCursor: "120", - }, - { - VersionLabel: "0.1.115", - UpdateCursor: "115", - }, - }, - }, - }, - license: &kotsv1beta1.License{ - Spec: kotsv1beta1.LicenseSpec{}, - }, - channelChanged: true, - installedVersions: []*downstreamtypes.DownstreamVersion{ - { - ChannelID: "different-channel", - VersionLabel: "0.1.117", - UpdateCursor: "117", - }, - }, - wantNoSemver: []string{}, - wantSemver: []string{}, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - req := require.New(t) - - for _, v := range tt.installedVersions { - s := semver.MustParse(v.VersionLabel) - v.Semver = &s - - c := cursor.MustParse(v.UpdateCursor) - v.Cursor = &c - } - - // cursor based - tt.license.Spec.IsSemverRequired = false - got, err := getMissingRequiredVersions(tt.airgap, tt.license, tt.installedVersions, tt.channelChanged) - req.NoError(err) - req.Equal(tt.wantNoSemver, got) - - // semver based - tt.license.Spec.IsSemverRequired = true - got, err = getMissingRequiredVersions(tt.airgap, tt.license, tt.installedVersions, tt.channelChanged) - req.NoError(err) - req.Equal(tt.wantSemver, got) - }) - } -} - func Test_canInstall(t *testing.T) { type args struct { beforeKotsKinds *kotsutil.KotsKinds diff --git a/pkg/api/handlers/types/types.go b/pkg/api/handlers/types/types.go index 9572c3e350..4db3ee406f 100644 --- a/pkg/api/handlers/types/types.go +++ b/pkg/api/handlers/types/types.go @@ -82,13 +82,8 @@ type ResponseGitOps struct { type ResponseCluster struct { ID string `json:"id"` Slug string `json:"slug"` - // RequiresUpgrade represents whether the embedded cluster config for the current app - // version is different from the currently deployed embedded cluster config - RequiresUpgrade bool `json:"requiresUpgrade"` // State represents the current state of the most recently deployed embedded cluster config State string `json:"state,omitempty"` - // NumInstallations represents the number of installation objects in the cluster - NumInstallations int `json:"numInstallations"` } type GetPendingAppResponse struct { diff --git a/pkg/api/reporting/types/types.go b/pkg/api/reporting/types/types.go index 4143e7fb87..b92e16c74e 100644 --- a/pkg/api/reporting/types/types.go +++ b/pkg/api/reporting/types/types.go @@ -1,52 +1,38 @@ package types -// This type is mimicked in the instance_report table. type ReportingInfo struct { - InstanceID string `json:"instance_id"` - ClusterID string `json:"cluster_id"` - Downstream DownstreamInfo `json:"downstream"` - AppStatus string `json:"app_status"` - IsKurl bool `json:"is_kurl"` - KurlNodeCountTotal int `json:"kurl_node_count_total"` - KurlNodeCountReady int `json:"kurl_node_count_ready"` - K8sVersion string `json:"k8s_version"` - K8sDistribution string `json:"k8s_distribution"` - UserAgent string `json:"user_agent"` - KOTSInstallID string `json:"kots_install_id"` - KURLInstallID string `json:"kurl_install_id"` - EmbeddedClusterID string `json:"embedded_cluster_id"` - EmbeddedClusterVersion string `json:"embedded_cluster_version"` - IsGitOpsEnabled bool `json:"is_gitops_enabled"` - GitOpsProvider string `json:"gitops_provider"` - SnapshotProvider string `json:"snapshot_provider"` - SnapshotFullSchedule string `json:"snapshot_full_schedule"` - SnapshotFullTTL string `json:"snapshot_full_ttl"` - SnapshotPartialSchedule string `json:"snapshot_partial_schedule"` - SnapshotPartialTTL string `json:"snapshot_partial_ttl"` + InstanceID string `json:"instance_id" yaml:"instance_id"` + ClusterID string `json:"cluster_id" yaml:"cluster_id"` + Downstream DownstreamInfo `json:"downstream" yaml:"downstream"` + AppStatus string `json:"app_status" yaml:"app_status"` + IsKurl bool `json:"is_kurl" yaml:"is_kurl"` + KurlNodeCountTotal int `json:"kurl_node_count_total" yaml:"kurl_node_count_total"` + KurlNodeCountReady int `json:"kurl_node_count_ready" yaml:"kurl_node_count_ready"` + K8sVersion string `json:"k8s_version" yaml:"k8s_version"` + K8sDistribution string `json:"k8s_distribution" yaml:"k8s_distribution"` + UserAgent string `json:"user_agent" yaml:"user_agent"` + KOTSInstallID string `json:"kots_install_id" yaml:"kots_install_id"` + KURLInstallID string `json:"kurl_install_id" yaml:"kurl_install_id"` + EmbeddedClusterID string `json:"embedded_cluster_id" yaml:"embedded_cluster_id"` + EmbeddedClusterVersion string `json:"embedded_cluster_version" yaml:"embedded_cluster_version"` + IsGitOpsEnabled bool `json:"is_gitops_enabled" yaml:"is_gitops_enabled"` + GitOpsProvider string `json:"gitops_provider" yaml:"gitops_provider"` + SnapshotProvider string `json:"snapshot_provider" yaml:"snapshot_provider"` + SnapshotFullSchedule string `json:"snapshot_full_schedule" yaml:"snapshot_full_schedule"` + SnapshotFullTTL string `json:"snapshot_full_ttl" yaml:"snapshot_full_ttl"` + SnapshotPartialSchedule string `json:"snapshot_partial_schedule" yaml:"snapshot_partial_schedule"` + SnapshotPartialTTL string `json:"snapshot_partial_ttl" yaml:"snapshot_partial_ttl"` } type DownstreamInfo struct { - Cursor string `json:"cursor"` - ChannelID string `json:"channel_id"` - ChannelName string `json:"channel_name"` - Sequence *int64 `json:"sequence"` - Source string `json:"source"` - Status string `json:"status"` - PreflightState string `json:"preflight_state"` - SkipPreflights bool `json:"skip_preflights"` - ReplHelmInstalls int `json:"repl_helm_installs"` - NativeHelmInstalls int `json:"native_helm_installs"` -} - -// This type is mimicked in the preflight_report table. -type PreflightStatus struct { - InstanceID string `json:"instance_id"` - ClusterID string `json:"cluster_id"` - Sequence int64 `json:"sequence"` - SkipPreflights bool `json:"skip_preflights"` - InstallStatus string `json:"install_status"` - IsCLI bool `json:"is_cli"` - PreflightStatus string `json:"preflight_status"` - AppStatus string `json:"app_status"` - KOTSVersion string `json:"kots_version"` + Cursor string `json:"cursor" yaml:"cursor"` + ChannelID string `json:"channel_id" yaml:"channel_id"` + ChannelName string `json:"channel_name" yaml:"channel_name"` + Sequence *int64 `json:"sequence" yaml:"sequence"` + Source string `json:"source" yaml:"source"` + Status string `json:"status" yaml:"status"` + PreflightState string `json:"preflight_state" yaml:"preflight_state"` + SkipPreflights bool `json:"skip_preflights" yaml:"skip_preflights"` + ReplHelmInstalls int `json:"repl_helm_installs" yaml:"repl_helm_installs"` + NativeHelmInstalls int `json:"native_helm_installs" yaml:"native_helm_installs"` } diff --git a/pkg/apiserver/server.go b/pkg/apiserver/server.go index 9323aa32c2..7cbf6aaf10 100644 --- a/pkg/apiserver/server.go +++ b/pkg/apiserver/server.go @@ -13,7 +13,6 @@ import ( "github.com/gorilla/mux" "github.com/replicatedhq/kots/pkg/automation" "github.com/replicatedhq/kots/pkg/binaries" - "github.com/replicatedhq/kots/pkg/embeddedcluster" "github.com/replicatedhq/kots/pkg/handlers" identitymigrate "github.com/replicatedhq/kots/pkg/identity/migrate" "github.com/replicatedhq/kots/pkg/informers" @@ -28,14 +27,15 @@ import ( "github.com/replicatedhq/kots/pkg/snapshotscheduler" "github.com/replicatedhq/kots/pkg/store" "github.com/replicatedhq/kots/pkg/supportbundle" + "github.com/replicatedhq/kots/pkg/update" "github.com/replicatedhq/kots/pkg/updatechecker" + "github.com/replicatedhq/kots/pkg/upgradeservice" "github.com/replicatedhq/kots/pkg/util" "golang.org/x/crypto/bcrypt" ) type APIServerParams struct { Version string - RqliteURI string AutocreateClusterToken string SharedPassword string } @@ -43,9 +43,6 @@ type APIServerParams struct { func Start(params *APIServerParams) { log.Printf("kotsadm version %s\n", params.Version) - // set some persistence variables - persistence.InitDB(params.RqliteURI) - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) if err := store.GetStore().WaitForReady(ctx); err != nil { log.Println("error waiting for ready") @@ -100,10 +97,6 @@ func Start(params *APIServerParams) { } defer op.Shutdown() - if err := embeddedcluster.InitClusterState(context.TODO(), k8sClientset, kotsStore); err != nil { - log.Println("Failed to initialize cluster state:", err) - } - if params.SharedPassword != "" { // TODO: this won't override the password in the database // it's only possible to set this in the kots run workflow @@ -118,6 +111,10 @@ func Start(params *APIServerParams) { panic(err) } + if err := update.InitAvailableUpdatesDir(); err != nil { + panic(err) + } + if err := reporting.Init(); err != nil { log.Println("failed to initialize reporting:", err) } @@ -194,8 +191,11 @@ func Start(params *APIServerParams) { * Static routes **********************************************************************/ - // to avoid confusion, we don't serve this in the dev env... - if os.Getenv("DISABLE_SPA_SERVING") != "1" { + // Serve the upgrade UI from the upgrade service + // CAUTION: modifying this route WILL break backwards compatibility + r.PathPrefix("/upgrade-service/app/{appSlug}").Methods("GET").HandlerFunc(upgradeservice.Proxy) + + if os.Getenv("DISABLE_SPA_SERVING") != "1" { // we don't serve this in the dev env spa := handlers.SPAHandler{} r.PathPrefix("/").Handler(spa) } else if os.Getenv("ENABLE_WEB_PROXY") == "1" { // for dev env diff --git a/pkg/apparchive/app.go b/pkg/apparchive/app.go index 976cb78dca..64f5bbbe12 100644 --- a/pkg/apparchive/app.go +++ b/pkg/apparchive/app.go @@ -9,8 +9,10 @@ import ( "path/filepath" "strings" + "github.com/mholt/archiver/v3" "github.com/pkg/errors" "github.com/replicatedhq/kots/pkg/base" + "github.com/replicatedhq/kots/pkg/filestore" "github.com/replicatedhq/kots/pkg/k8sutil" "github.com/replicatedhq/kots/pkg/kotsutil" "github.com/replicatedhq/kots/pkg/util" @@ -267,3 +269,69 @@ func filterChartsInBasePath(basePath string) func(path string) (bool, error) { return false, nil } } + +// CreateAppVersionArchive takes an unarchived app, makes an archive and then +// writes it to the filestore at the given output path +func CreateAppVersionArchive(archivePath string, outputPath string) error { + paths := []string{ + filepath.Join(archivePath, "upstream"), + } + + basePath := filepath.Join(archivePath, "base") + if _, err := os.Stat(basePath); err == nil { + paths = append(paths, basePath) + } + + overlaysPath := filepath.Join(archivePath, "overlays") + if _, err := os.Stat(overlaysPath); err == nil { + paths = append(paths, overlaysPath) + } + + renderedPath := filepath.Join(archivePath, "rendered") + if _, err := os.Stat(renderedPath); err == nil { + paths = append(paths, renderedPath) + } + + kotsKindsPath := filepath.Join(archivePath, "kotsKinds") + if _, err := os.Stat(kotsKindsPath); err == nil { + paths = append(paths, kotsKindsPath) + } + + helmPath := filepath.Join(archivePath, "helm") + if _, err := os.Stat(helmPath); err == nil { + paths = append(paths, helmPath) + } + + skippedFilesPath := filepath.Join(archivePath, "skippedFiles") + if _, err := os.Stat(skippedFilesPath); err == nil { + paths = append(paths, skippedFilesPath) + } + + tmpDir, err := os.MkdirTemp("", "kotsadm") + if err != nil { + return errors.Wrap(err, "failed to create temp file") + } + defer os.RemoveAll(tmpDir) + fileToWrite := filepath.Join(tmpDir, "archive.tar.gz") + + tarGz := archiver.TarGz{ + Tar: &archiver.Tar{ + ImplicitTopLevelFolder: false, + }, + } + if err := tarGz.Archive(paths, fileToWrite); err != nil { + return errors.Wrap(err, "failed to create archive") + } + + f, err := os.Open(fileToWrite) + if err != nil { + return errors.Wrap(err, "failed to open archive file") + } + defer f.Close() + + if err := filestore.GetStore().WriteArchive(outputPath, f); err != nil { + return errors.Wrap(err, "failed to write archive") + } + + return nil +} diff --git a/pkg/archives/airgap.go b/pkg/archives/airgap.go new file mode 100644 index 0000000000..6515efad43 --- /dev/null +++ b/pkg/archives/airgap.go @@ -0,0 +1,96 @@ +package archives + +import ( + "archive/tar" + "compress/gzip" + "io" + "os" + "path/filepath" + + "github.com/pkg/errors" +) + +func ExtractAppMetaFromAirgapBundle(airgapBundle string) (string, error) { + destDir, err := os.MkdirTemp("", "kotsadm-app-meta-") + if err != nil { + return "", errors.Wrap(err, "failed to create temp dir") + } + metaFiles := []string{ + "airgap.yaml", + "app.tar.gz", + } + for _, fileName := range metaFiles { + content, err := GetFileContentFromTGZArchive(fileName, airgapBundle) + if err != nil { + return "", errors.Wrapf(err, "failed to get %s from bundle", fileName) + } + if err := os.WriteFile(filepath.Join(destDir, fileName), []byte(content), 0644); err != nil { + return "", errors.Wrapf(err, "failed to write %s", fileName) + } + } + return destDir, nil +} + +func FilterAirgapBundle(airgapBundle string, filesToKeep []string) (string, error) { + f, err := os.CreateTemp("", "kots-airgap") + if err != nil { + return "", errors.Wrap(err, "failed to create temp file") + } + defer f.Close() + + gw := gzip.NewWriter(f) + defer gw.Close() + + tw := tar.NewWriter(gw) + defer tw.Close() + + fileFilter := make(map[string]bool) + for _, file := range filesToKeep { + fileFilter[file] = true + } + + fileReader, err := os.Open(airgapBundle) + if err != nil { + return "", errors.Wrap(err, "failed to open airgap bundle") + } + defer fileReader.Close() + + gzipReader, err := gzip.NewReader(fileReader) + if err != nil { + return "", errors.Wrap(err, "failed to get new gzip reader") + } + defer gzipReader.Close() + + tarReader := tar.NewReader(gzipReader) + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return "", errors.Wrap(err, "failed to get read archive") + } + + if _, ok := fileFilter[header.Name]; !ok { + continue + } + + if err := tw.WriteHeader(header); err != nil { + return "", errors.Wrapf(err, "failed to write tar header for %s", header.Name) + } + _, err = io.Copy(tw, tarReader) + if err != nil { + return "", errors.Wrapf(err, "failed to write %s to tar", header.Name) + } + } + + if err := tw.Close(); err != nil { + return "", errors.Wrap(err, "failed to close tar writer") + } + + if err := gw.Close(); err != nil { + return "", errors.Wrap(err, "failed to close gzip writer") + } + + return f.Name(), nil +} diff --git a/pkg/archives/airgap_test.go b/pkg/archives/airgap_test.go new file mode 100644 index 0000000000..58db544247 --- /dev/null +++ b/pkg/archives/airgap_test.go @@ -0,0 +1,78 @@ +package archives + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/replicatedhq/kots/pkg/util" +) + +func TestFilterAirgapBundle(t *testing.T) { + tests := []struct { + name string + bundleFiles map[string]string + filesToInclude []string + wantBundleFiles map[string]string + }{ + { + name: "slim airgap bundle", + bundleFiles: map[string]string{ + "airgap.yaml": "airgap-metadata", + "app.tar.gz": "application-archive", + "embedded-cluster/artifacts/kots": "kots-binary", + "images": "image-data", + }, + filesToInclude: []string{ + "airgap.yaml", + "app.tar.gz", + "embedded-cluster/artifacts/kots", + }, + wantBundleFiles: map[string]string{ + "airgap.yaml": "airgap-metadata", + "app.tar.gz": "application-archive", + "embedded-cluster/artifacts/kots": "kots-binary", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, err := util.FilesToTGZ(tt.bundleFiles) + if err != nil { + t.Errorf("failed to create tgz: %v", err) + } + + airgapBundlePath := filepath.Join(t.TempDir(), "application.airgap") + tmpFile, err := os.Create(airgapBundlePath) + if err != nil { + t.Errorf("failed to create tmp file: %v", err) + } + + _, err = tmpFile.Write(b) + if err != nil { + t.Errorf("failed to write to tmp file: %v", err) + } + + got, err := FilterAirgapBundle(airgapBundlePath, tt.filesToInclude) + if err != nil { + t.Errorf("FilterAirgapBundle() error = %v", err) + return + } + + b, err = os.ReadFile(got) + if err != nil { + t.Errorf("failed to read filtered airgap bundle: %v", err) + } + + gotFiles, err := util.TGZToFiles(b) + if err != nil { + t.Errorf("failed to convert filtered airgap bundle to files: %v", err) + } + + if !reflect.DeepEqual(gotFiles, tt.wantBundleFiles) { + t.Errorf("FilterAirgapBundle() = %v, want %v", gotFiles, tt.wantBundleFiles) + } + }) + } +} diff --git a/pkg/archives/archives.go b/pkg/archives/archives.go index e2ca26467c..41eb94b7a9 100644 --- a/pkg/archives/archives.go +++ b/pkg/archives/archives.go @@ -28,7 +28,7 @@ func ExtractTGZArchiveFromFile(tgzFile string, destDir string) error { return nil } -func DirExistsInAirgap(dirToCheck string, archive string) (bool, error) { +func DirExistsInTGZArchive(dirToCheck string, archive string) (bool, error) { fileReader, err := os.Open(archive) if err != nil { return false, errors.Wrap(err, "failed to open file") @@ -64,28 +64,41 @@ func DirExistsInAirgap(dirToCheck string, archive string) (bool, error) { return false, nil } -func GetFileFromAirgap(fileToGet string, archive string) ([]byte, error) { - fileReader, err := os.Open(archive) +func GetFileContentFromTGZArchive(fileToGet string, archive string) ([]byte, error) { + file, err := GetFileFromTGZArchive(fileToGet, archive) if err != nil { - return nil, errors.Wrap(err, "failed to open file") + return nil, err } - defer fileReader.Close() + defer os.Remove(file) - gzipReader, err := gzip.NewReader(fileReader) + content, err := os.ReadFile(file) + if err != nil { + return nil, errors.Wrap(err, "failed to read file") + } + return content, nil +} + +func GetFileFromTGZArchive(fileToGet string, archive string) (string, error) { + archiveReader, err := os.Open(archive) if err != nil { - return nil, errors.Wrap(err, "failed to get new gzip reader") + return "", errors.Wrap(err, "failed to open file") + } + defer archiveReader.Close() + + gzipReader, err := gzip.NewReader(archiveReader) + if err != nil { + return "", errors.Wrap(err, "failed to get new gzip reader") } defer gzipReader.Close() tarReader := tar.NewReader(gzipReader) - var fileData []byte for { header, err := tarReader.Next() if err == io.EOF { break } if err != nil { - return nil, errors.Wrap(err, "failed to get read archive") + return "", errors.Wrap(err, "failed to get read archive") } if header.Typeflag != tar.TypeReg { @@ -95,21 +108,20 @@ func GetFileFromAirgap(fileToGet string, archive string) ([]byte, error) { continue } - buf := new(bytes.Buffer) - _, err = buf.ReadFrom(tarReader) + tmpFile, err := os.CreateTemp("", filepath.Base(fileToGet)) if err != nil { - return nil, errors.Wrap(err, "failed to read file from tar archive") + return "", errors.Wrap(err, "failed to create temporary file") } + defer tmpFile.Close() - fileData = buf.Bytes() - break - } - - if fileData == nil { - return nil, errors.New("file not found in archive") + _, err = io.Copy(tmpFile, tarReader) + if err != nil { + return "", errors.Wrap(err, "failed to write tar archive to temporary file") + } + return tmpFile.Name(), nil } - return fileData, nil + return "", errors.New("file not found in archive") } func ExtractTGZArchiveFromReader(tgzReader io.Reader, destDir string) error { diff --git a/pkg/automation/automation.go b/pkg/automation/automation.go index 997a2481a0..3ce8d237d5 100644 --- a/pkg/automation/automation.go +++ b/pkg/automation/automation.go @@ -27,6 +27,7 @@ import ( "github.com/replicatedhq/kots/pkg/replicatedapp" "github.com/replicatedhq/kots/pkg/store" storetypes "github.com/replicatedhq/kots/pkg/store/types" + "github.com/replicatedhq/kots/pkg/tasks" "github.com/replicatedhq/kots/pkg/util" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "go.uber.org/zap" @@ -147,7 +148,7 @@ func installLicenseSecret(clientset *kubernetes.Clientset, licenseSecret corev1. return errors.Wrap(err, "failed to marshal task message") } taskID := fmt.Sprintf("automated-install-slug-%s", appSlug) - if err := store.GetStore().SetTaskStatus(taskID, string(taskMessage), AutomatedInstallRunning); err != nil { + if err := tasks.SetTaskStatus(taskID, string(taskMessage), AutomatedInstallRunning); err != nil { logger.Error(errors.Wrap(err, "failed to set task status")) } @@ -156,8 +157,8 @@ func installLicenseSecret(clientset *kubernetes.Clientset, licenseSecret corev1. go func() { for { select { - case <-time.After(time.Second): - if err := store.GetStore().UpdateTaskStatusTimestamp(taskID); err != nil { + case <-time.After(time.Second * 2): + if err := tasks.UpdateTaskStatusTimestamp(taskID); err != nil { logger.Error(errors.Wrapf(err, "failed to update task %s", taskID)) } case <-finishedCh: @@ -183,7 +184,7 @@ func installLicenseSecret(clientset *kubernetes.Clientset, licenseSecret corev1. if err != nil { logger.Error(errors.Wrap(err, "failed to marshal task message")) } - if err := store.GetStore().SetTaskStatus(taskID, string(taskMessage), AutomatedInstallSuccess); err != nil { + if err := tasks.SetTaskStatus(taskID, string(taskMessage), AutomatedInstallSuccess); err != nil { logger.Error(errors.Wrap(err, "failed to set error on install task status")) } } else { @@ -191,7 +192,7 @@ func installLicenseSecret(clientset *kubernetes.Clientset, licenseSecret corev1. if err != nil { logger.Error(errors.Wrap(err, "failed to marshal task message")) } - if err := store.GetStore().SetTaskStatus(taskID, string(taskMessage), AutomatedInstallFailed); err != nil { + if err := tasks.SetTaskStatus(taskID, string(taskMessage), AutomatedInstallFailed); err != nil { logger.Error(errors.Wrap(err, "failed to set error on install task status")) } } @@ -226,7 +227,7 @@ func installLicenseSecret(clientset *kubernetes.Clientset, licenseSecret corev1. return errors.Wrapf(err, "failed to check if license already exists for app %s", appSlug) } if existingLicense != nil { - resolved, err := kotslicense.ResolveExistingLicense(verifiedLicense) + resolved, err := kotsadmlicense.ResolveExistingLicense(verifiedLicense) if err != nil { logger.Error(errors.Wrap(err, "failed to resolve existing license conflict")) } diff --git a/pkg/docker/registry/temp_registry.go b/pkg/docker/registry/temp_registry.go index 7917397994..6665a890ba 100644 --- a/pkg/docker/registry/temp_registry.go +++ b/pkg/docker/registry/temp_registry.go @@ -25,8 +25,8 @@ import ( var tempRegistryConfigYML string type TempRegistry struct { - process *os.Process - port string + cmd *exec.Cmd + port string } // Start will spin up a docker registry service in the background on a random port. @@ -52,11 +52,11 @@ func (r *TempRegistry) Start(rootDir string) (finalError error) { configYMLCopy := strings.Replace(tempRegistryConfigYML, "__ROOT_DIR__", rootDir, 1) configYMLCopy = strings.Replace(configYMLCopy, "__PORT__", freePort, 1) - configFile, err := ioutil.TempFile("", "registryconfig") + configFile, err := os.CreateTemp("", "registryconfig") if err != nil { return errors.Wrap(err, "failed to create temp file for config") } - if err := ioutil.WriteFile(configFile.Name(), []byte(configYMLCopy), 0644); err != nil { + if err := os.WriteFile(configFile.Name(), []byte(configYMLCopy), 0644); err != nil { return errors.Wrap(err, "failed to write config to temp file") } defer os.RemoveAll(configFile.Name()) @@ -70,8 +70,11 @@ func (r *TempRegistry) Start(rootDir string) (finalError error) { return errors.Wrap(err, "failed to start") } + // calling wait helps reap the zombie process + go cmd.Wait() + + r.cmd = cmd r.port = freePort - r.process = cmd.Process if err := r.WaitForReady(time.Second * 30); err != nil { return errors.Wrap(err, "failed to wait for registry to become ready") @@ -81,35 +84,37 @@ func (r *TempRegistry) Start(rootDir string) (finalError error) { } func (r *TempRegistry) Stop() { - if r.process != nil { - if err := r.process.Signal(os.Interrupt); err != nil { - logger.Debugf("Failed to stop registry process on port %s", r.port) + if r.cmd != nil && r.cmd.ProcessState == nil { + if err := r.cmd.Process.Signal(os.Interrupt); err != nil { + logger.Errorf("Failed to stop registry process on port %s", r.port) } } + r.cmd = nil r.port = "" - r.process = nil } func (r *TempRegistry) WaitForReady(timeout time.Duration) error { start := time.Now() - + var lasterr error for { - url := fmt.Sprintf("http://localhost:%s", r.port) - newRequest, err := http.NewRequest("GET", url, nil) - if err == nil { - resp, err := http.DefaultClient.Do(newRequest) - if err == nil { - if resp.StatusCode == http.StatusOK { - return nil - } - } + if time.Sleep(time.Second); time.Since(start) > timeout { + return errors.Errorf("Timeout waiting for registry to become ready on port %s. last error: %v", r.port, lasterr) } - - time.Sleep(time.Second) - - if time.Since(start) > timeout { - return errors.Errorf("Timeout waiting for registry to become ready on port %s", r.port) + request, err := http.NewRequest("GET", fmt.Sprintf("http://localhost:%s", r.port), nil) + if err != nil { + lasterr = errors.Wrap(err, "failed to create request") + continue + } + response, err := http.DefaultClient.Do(request) + if err != nil { + lasterr = errors.Wrap(err, "failed to do request") + continue + } + if response.StatusCode != http.StatusOK { + lasterr = errors.Errorf("unexpected status code %d", response.StatusCode) + continue } + return nil } } diff --git a/pkg/embeddedcluster/monitor.go b/pkg/embeddedcluster/monitor.go index b5f71030f2..add938581d 100644 --- a/pkg/embeddedcluster/monitor.go +++ b/pkg/embeddedcluster/monitor.go @@ -1,151 +1,61 @@ package embeddedcluster import ( + "bytes" "context" + "encoding/json" "errors" "fmt" - "sync" - "time" - embeddedclusterv1beta1 "github.com/replicatedhq/embedded-cluster-kinds/apis/v1beta1" - appstatetypes "github.com/replicatedhq/kots/pkg/appstate/types" - "github.com/replicatedhq/kots/pkg/k8sutil" "github.com/replicatedhq/kots/pkg/kotsutil" "github.com/replicatedhq/kots/pkg/logger" - "github.com/replicatedhq/kots/pkg/store" + registrytypes "github.com/replicatedhq/kots/pkg/registry/types" "github.com/replicatedhq/kots/pkg/util" - "k8s.io/client-go/kubernetes" + kbclient "sigs.k8s.io/controller-runtime/pkg/client" ) -var stateMut = sync.Mutex{} - -// MaybeStartClusterUpgrade checks if the embedded cluster is in a state that requires an upgrade. If so, -// it starts the upgrade process. We only start an upgrade if the following conditions are met: +// RequiresClusterUpgrade returns true if the embedded cluster is in a state that requires an upgrade. +// This is determined by checking that: // - The app has an embedded cluster configuration. -// - The app embedded cluster configuration differs from the current embedded cluster config. +// - The app embedded cluster configuration differs from the current embedded cluster configuration. // - The current cluster config (as part of the Installation object) already exists in the cluster. -func MaybeStartClusterUpgrade(ctx context.Context, store store.Store, kotsKinds *kotsutil.KotsKinds, appID string) error { +func RequiresClusterUpgrade(ctx context.Context, kbClient kbclient.Client, kotsKinds *kotsutil.KotsKinds) (bool, error) { if kotsKinds == nil || kotsKinds.EmbeddedClusterConfig == nil { - return nil + return false, nil } - if !util.IsEmbeddedCluster() { - return nil + return false, nil } - - kbClient, err := k8sutil.GetKubeClient(ctx) + curcfg, err := ClusterConfig(ctx, kbClient) if err != nil { - return fmt.Errorf("failed to get kubeclient: %w", err) - } - - spec := kotsKinds.EmbeddedClusterConfig.Spec - if upgrade, err := RequiresUpgrade(ctx, kbClient, spec); err != nil { // if there is no installation object we can't start an upgrade. this is a valid // scenario specially during cluster bootstrap. as we do not need to upgrade the // cluster just after its installation we can return nil here. // (the cluster in the first kots version will match the cluster installed during bootstrap) if errors.Is(err, ErrNoInstallations) { - return nil + return false, nil } - return fmt.Errorf("failed to check if upgrade is required: %w", err) - } else if !upgrade { - return nil + return false, fmt.Errorf("failed to get current cluster config: %w", err) } - - // we need to wait for the application to be ready before we can start the upgrade. - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return nil - case <-ticker.C: - } - - appStatus, err := store.GetAppStatus(appID) - if err != nil { - return fmt.Errorf("failed to get app status: %w", err) - } - - if appStatus.State != appstatetypes.StateReady { - logger.Infof("waiting for app to be ready before starting cluster upgrade. current state: %s", appStatus.State) - continue - } - - artifacts := getArtifactsFromInstallation(kotsKinds.Installation, kotsKinds.License.Spec.AppSlug) - - if err := startClusterUpgrade(ctx, spec, artifacts, *kotsKinds.License); err != nil { - return fmt.Errorf("failed to start cluster upgrade: %w", err) - } - logger.Info("started cluster upgrade") - - go watchClusterState(ctx, store) - - return nil + serializedCur, err := json.Marshal(curcfg) + if err != nil { + return false, err } -} - -// InitClusterState initializes the cluster state in the database. This should be called when the -// server launches. -func InitClusterState(ctx context.Context, client kubernetes.Interface, store store.Store) error { - if util.IsEmbeddedCluster() { - go watchClusterState(ctx, store) - return nil + serializedNew, err := json.Marshal(kotsKinds.EmbeddedClusterConfig.Spec) + if err != nil { + return false, err } - return nil + return !bytes.Equal(serializedCur, serializedNew), nil } -// watchClusterState checks the status of the installation object and updates the cluster state -// after the cluster state has been 'installed' for 30 seconds, it will exit the loop. -// this function is blocking and should be run in a goroutine. -// if it is called multiple times, only one instance will run. -func watchClusterState(ctx context.Context, store store.Store) { - stateMut.Lock() - defer stateMut.Unlock() - numReady := 0 - lastState := "" - for numReady < 6 { - select { - case <-ctx.Done(): - return - case <-time.After(time.Second * 5): - } - state, err := updateClusterState(ctx, store, lastState) - if err != nil { - logger.Errorf("embeddedcluster monitor: fail updating state: %v", err) - } +func StartClusterUpgrade(ctx context.Context, kotsKinds *kotsutil.KotsKinds, registrySettings registrytypes.RegistrySettings) error { + spec := kotsKinds.EmbeddedClusterConfig.Spec + artifacts := getArtifactsFromInstallation(kotsKinds.Installation) - if state == embeddedclusterv1beta1.InstallationStateInstalled { - numReady++ - } else { - numReady = 0 - } - lastState = state + if err := startClusterUpgrade(ctx, spec, artifacts, registrySettings, *kotsKinds.License, kotsKinds.Installation.Spec.VersionLabel); err != nil { + return fmt.Errorf("failed to start cluster upgrade: %w", err) } -} + logger.Info("started cluster upgrade") -// updateClusterState updates the cluster state in the database. Gets the state from the cluster -// by reading the latest embedded cluster installation CRD. -// If the lastState is the same as the current state, it will not update the database. -func updateClusterState(ctx context.Context, store store.Store, lastState string) (string, error) { - kbClient, err := k8sutil.GetKubeClient(ctx) - if err != nil { - return "", fmt.Errorf("failed to get kubeclient: %w", err) - } - installation, err := GetCurrentInstallation(ctx, kbClient) - if err != nil { - return "", fmt.Errorf("failed to get current installation: %w", err) - } - state := embeddedclusterv1beta1.InstallationStateUnknown - if installation.Status.State != "" { - state = installation.Status.State - } - // only update the state if it has changed - if state != lastState { - if err := store.SetEmbeddedClusterState(state); err != nil { - return "", fmt.Errorf("failed to update embedded cluster state: %w", err) - } - } - return state, nil + return nil } diff --git a/pkg/embeddedcluster/oras.go b/pkg/embeddedcluster/oras.go new file mode 100644 index 0000000000..d5f4adcc8d --- /dev/null +++ b/pkg/embeddedcluster/oras.go @@ -0,0 +1,57 @@ +package embeddedcluster + +import ( + "context" + "fmt" + + "go.uber.org/multierr" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content/file" + "oras.land/oras-go/v2/registry" + "oras.land/oras-go/v2/registry/remote" +) + +type pullArtifactOptions struct { + client remote.Client +} + +// pullArtifact fetches an artifact from the registry pointed by 'from'. The artifact +// is stored in a temporary directory and the path to this directory is returned. +// Callers are responsible for removing the temporary directory when it is no longer +// needed. In case of error, the temporary directory is removed here. +func pullArtifact(ctx context.Context, srcRepo, dstDir string, opts pullArtifactOptions) error { + imgref, err := registry.ParseReference(srcRepo) + if err != nil { + return fmt.Errorf("parse image reference: %w", err) + } + + repo, err := remote.NewRepository(srcRepo) + if err != nil { + return fmt.Errorf("create repository: %w", err) + } + + fs, err := file.New(dstDir) + if err != nil { + return fmt.Errorf("create file store: %w", err) + } + defer fs.Close() + + if opts.client != nil { + repo.Client = opts.client + } + + tag := imgref.Reference + _, tlserr := oras.Copy(ctx, repo, tag, fs, tag, oras.DefaultCopyOptions) + if tlserr == nil { + return nil + } + + // if we fail to fetch the artifact using https we gonna try once more using plain + // http as some versions of the registry were deployed without tls. + repo.PlainHTTP = true + if _, err := oras.Copy(ctx, repo, tag, fs, tag, oras.DefaultCopyOptions); err != nil { + err = multierr.Combine(tlserr, err) + return fmt.Errorf("fetch artifacts with or without tls: %w", err) + } + return nil +} diff --git a/pkg/embeddedcluster/upgrade.go b/pkg/embeddedcluster/upgrade.go new file mode 100644 index 0000000000..679d1b4762 --- /dev/null +++ b/pkg/embeddedcluster/upgrade.go @@ -0,0 +1,374 @@ +package embeddedcluster + +import ( + "bufio" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/mholt/archiver/v3" + embeddedclusterv1beta1 "github.com/replicatedhq/embedded-cluster-kinds/apis/v1beta1" + embeddedclustertypes "github.com/replicatedhq/embedded-cluster-kinds/types" + dockerregistrytypes "github.com/replicatedhq/kots/pkg/docker/registry/types" + "github.com/replicatedhq/kots/pkg/imageutil" + "github.com/replicatedhq/kots/pkg/k8sutil" + registrytypes "github.com/replicatedhq/kots/pkg/registry/types" + "github.com/replicatedhq/kots/pkg/util" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials" + k8syaml "sigs.k8s.io/yaml" +) + +// startClusterUpgrade will create a new installation with the provided config. +func startClusterUpgrade( + ctx context.Context, newcfg embeddedclusterv1beta1.ConfigSpec, + artifacts *embeddedclusterv1beta1.ArtifactsLocation, + registrySettings registrytypes.RegistrySettings, + license kotsv1beta1.License, versionLabel string, +) error { + // TODO(upgrade): put a lock here to prevent multiple upgrades at the same time + + kbClient, err := k8sutil.GetKubeClient(ctx) + if err != nil { + return fmt.Errorf("failed to get kubeclient: %w", err) + } + + k8sClient, err := k8sutil.GetClientset() + if err != nil { + return fmt.Errorf("failed to get clientset: %w", err) + } + + current, err := GetCurrentInstallation(ctx, kbClient) + if err != nil { + return fmt.Errorf("failed to get current installation: %w", err) + } + newins := &embeddedclusterv1beta1.Installation{ + TypeMeta: metav1.TypeMeta{ + APIVersion: embeddedclusterv1beta1.GroupVersion.String(), + Kind: "Installation", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: time.Now().Format("20060102150405"), + Labels: map[string]string{ + "replicated.com/disaster-recovery": "ec-install", + }, + }, + Spec: embeddedclusterv1beta1.InstallationSpec{ + ClusterID: current.Spec.ClusterID, + MetricsBaseURL: current.Spec.MetricsBaseURL, + HighAvailability: current.Spec.HighAvailability, + AirGap: current.Spec.AirGap, + Network: current.Spec.Network, + Artifacts: artifacts, + Config: &newcfg, + EndUserK0sConfigOverrides: current.Spec.EndUserK0sConfigOverrides, + BinaryName: current.Spec.BinaryName, + LicenseInfo: &embeddedclusterv1beta1.LicenseInfo{IsDisasterRecoverySupported: license.Spec.IsDisasterRecoverySupported}, + }, + } + + log.Printf("Starting cluster upgrade to version %s...", newcfg.Version) + + err = runClusterUpgrade(ctx, k8sClient, newins, registrySettings, license, versionLabel) + if err != nil { + return fmt.Errorf("run cluster upgrade: %w", err) + } + + log.Printf("Cluster upgrade to version %s started successfully", newcfg.Version) + + return nil +} + +// runClusterUpgrade will download the new embedded cluster operator binary and run the upgrade +// command with the provided installation data. This is needed to get the latest +// embeddedclusterv1beta1 API version. The upgrade command will first upgrade the embedded cluster +// operator, wait for the CRD to be up-to-date, and then apply the installation object. +func runClusterUpgrade( + ctx context.Context, k8sClient kubernetes.Interface, + in *embeddedclusterv1beta1.Installation, registrySettings registrytypes.RegistrySettings, + license kotsv1beta1.License, versionLabel string, +) error { + var bin string + + if in.Spec.AirGap { + artifact := in.Spec.Artifacts.AdditionalArtifacts["operator"] + if artifact == "" { + return fmt.Errorf("missing operator binary in airgap artifacts") + } + + b, err := pullUpgradeBinaryFromRegistry(ctx, k8sClient, registrySettings, artifact) + if err != nil { + return fmt.Errorf("pull upgrade binary from registry: %w", err) + } + bin = b + } else { + b, err := downloadUpgradeBinary(ctx, license, versionLabel) + if err != nil { + return fmt.Errorf("download upgrade binary: %w", err) + } + bin = b + } + defer os.RemoveAll(bin) + + err := os.Chmod(bin, 0755) + if err != nil { + return fmt.Errorf("chmod upgrade binary: %w", err) + } + + installationData, err := k8syaml.Marshal(in) + if err != nil { + return fmt.Errorf("marshal installation: %w", err) + } + + log.Println("Running upgrade command...") + + args := []string{"upgrade"} + if in.Spec.AirGap { + // TODO(upgrade): local-artifact-mirror-image should be included in the installation object + localArtifactMirrorImage, err := getLocalArtifactMirrorImage(ctx, k8sClient, in, registrySettings) + if err != nil { + return fmt.Errorf("get local artifact mirror image: %w", err) + } + args = append(args, "--local-artifact-mirror-image", localArtifactMirrorImage) + } + args = append(args, "--installation", "-") + + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Stdin = strings.NewReader(string(installationData)) + pr, pw := io.Pipe() + defer pw.Close() + cmd.Stdout = pw + cmd.Stderr = pw + go func() { + defer pr.Close() + log.Println("Upgrade command output:") + scanner := bufio.NewScanner(pr) + for scanner.Scan() { + log.Println(" " + scanner.Text()) + } + }() + err = cmd.Run() + if err != nil { + return fmt.Errorf("run upgrade command: %w", err) + } + + return nil +} + +const ( + // TODO(upgrade): perhaps do not hardcode these + upgradeBinary = "operator" + upgradeBinaryOCIAsset = "operator.tar.gz" +) + +func downloadUpgradeBinary(ctx context.Context, license kotsv1beta1.License, versionLabel string) (string, error) { + tmpdir, err := os.MkdirTemp("", "embedded-cluster-artifact-*") + if err != nil { + return "", fmt.Errorf("create temp dir: %w", err) + } + + req, err := newDownloadUpgradeBinaryRequest(ctx, license, versionLabel) + if err != nil { + return "", fmt.Errorf("new download upgrade binary request: %w", err) + } + + log.Printf("Downloading upgrade binary from %s...", req.URL) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("do request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + archiveFilepath := filepath.Join(tmpdir, "operator.tar.gz") + f, err := os.Create(archiveFilepath) + if err != nil { + return "", fmt.Errorf("create temp file: %w", err) + } + defer os.RemoveAll(f.Name()) + defer f.Close() + + _, err = io.Copy(f, resp.Body) + if err != nil { + return "", fmt.Errorf("copy response body: %w", err) + } + + err = unarchive(archiveFilepath, tmpdir) + if err != nil { + return "", fmt.Errorf("unarchive: %w", err) + } + + return filepath.Join(tmpdir, upgradeBinary), nil +} + +func newDownloadUpgradeBinaryRequest(ctx context.Context, license kotsv1beta1.License, versionLabel string) (*http.Request, error) { + url := fmt.Sprintf("%s/clusterconfig/artifact/operator?versionLabel=%s", license.Spec.Endpoint, versionLabel) + req, err := util.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + req.SetBasicAuth(license.Spec.LicenseID, license.Spec.LicenseID) + req = req.WithContext(ctx) + + return req, nil +} + +func pullUpgradeBinaryFromRegistry( + ctx context.Context, k8sClient kubernetes.Interface, + registrySettings registrytypes.RegistrySettings, + repo string, +) (string, error) { + tmpdir, err := os.MkdirTemp("", "embedded-cluster-artifact-*") + if err != nil { + return "", fmt.Errorf("create temp dir: %w", err) + } + + log.Printf("Pulling upgrade binary from %s...", repo) + + err = pullFromRegistry(ctx, k8sClient, registrySettings, repo, tmpdir) + if err != nil { + return "", fmt.Errorf("pull from registry: %w", err) + } + + err = unarchive(filepath.Join(tmpdir, upgradeBinaryOCIAsset), tmpdir) + if err != nil { + return "", fmt.Errorf("unarchive: %w", err) + } + + return filepath.Join(tmpdir, upgradeBinary), nil +} + +const ( + localArtifactMirrorMetadataKey = "local-artifact-mirror-image" +) + +func getLocalArtifactMirrorImage( + ctx context.Context, k8sClient kubernetes.Interface, + in *embeddedclusterv1beta1.Installation, registrySettings registrytypes.RegistrySettings, +) (string, error) { + path, err := pullEmbeddedClusterMetadataFromRegistry(ctx, k8sClient, registrySettings, in.Spec.Artifacts.EmbeddedClusterMetadata) + if err != nil { + return "", fmt.Errorf("pull embedded cluster metadata from registry: %w", err) + } + + f, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("open metadata file: %w", err) + } + defer f.Close() + + var metadata embeddedclustertypes.ReleaseMetadata + err = json.NewDecoder(f).Decode(&metadata) + if err != nil { + return "", fmt.Errorf("decode metadata: %w", err) + } + + srcImage, ok := metadata.Artifacts[localArtifactMirrorMetadataKey] + if !ok { + return "", fmt.Errorf("missing local artifact mirror image in embedded cluster metadata") + } + + imageName, err := embeddedRegistryImageName(registrySettings, srcImage) + if err != nil { + return "", fmt.Errorf("get image name: %w", err) + } + + return imageName, nil +} + +func pullEmbeddedClusterMetadataFromRegistry( + ctx context.Context, k8sClient kubernetes.Interface, + registrySettings registrytypes.RegistrySettings, + repo string, +) (string, error) { + tmpdir, err := os.MkdirTemp("", "embedded-cluster-artifact-*") + if err != nil { + return "", fmt.Errorf("create temp dir: %w", err) + } + + log.Printf("Pulling version metadata from %s...", repo) + + err = pullFromRegistry(ctx, k8sClient, registrySettings, repo, tmpdir) + if err != nil { + return "", fmt.Errorf("pull from registry: %w", err) + } + + return filepath.Join(tmpdir, "version-metadata.json"), nil +} + +func pullFromRegistry( + ctx context.Context, k8sClient kubernetes.Interface, + registrySettings registrytypes.RegistrySettings, + srcRepo string, dstDir string, +) error { + store := credentials.NewMemoryStore() + err := store.Put(ctx, registrySettings.Hostname, auth.Credential{ + Username: registrySettings.Username, + Password: registrySettings.Password, + }) + if err != nil { + return fmt.Errorf("put credential: %w", err) + } + + transp, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return fmt.Errorf("default transport is not http.Transport") + } + transp = transp.Clone() + transp.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + + opts := pullArtifactOptions{} + opts.client = &auth.Client{ + Client: &http.Client{Transport: transp}, + Credential: store.Get, + } + + err = pullArtifact(ctx, srcRepo, dstDir, opts) + if err != nil { + return fmt.Errorf("pull oci artifact: %w", err) + } + + return nil +} + +func unarchive(archiveFilepath string, dstDir string) error { + tarGz := archiver.TarGz{ + Tar: &archiver.Tar{ + ImplicitTopLevelFolder: false, + }, + } + + err := tarGz.Unarchive(archiveFilepath, dstDir) + if err != nil { + return err + } + + return nil +} + +func embeddedRegistryImageName(registrySettings registrytypes.RegistrySettings, srcImage string) (string, error) { + destRegistry := dockerregistrytypes.RegistryOptions{ + Endpoint: registrySettings.Hostname, + Namespace: registrySettings.Namespace, + Username: registrySettings.Username, + Password: registrySettings.Password, + } + + return imageutil.DestECImage(destRegistry, srcImage) +} diff --git a/pkg/embeddedcluster/util.go b/pkg/embeddedcluster/util.go index 7ab8cffdd5..8a7ece4667 100644 --- a/pkg/embeddedcluster/util.go +++ b/pkg/embeddedcluster/util.go @@ -4,16 +4,14 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "sort" - "time" embeddedclusterv1beta1 "github.com/replicatedhq/embedded-cluster-kinds/apis/v1beta1" - "github.com/replicatedhq/kots/pkg/k8sutil" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" k8stypes "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes" kbclient "sigs.k8s.io/controller-runtime/pkg/client" @@ -35,6 +33,13 @@ func IsHA(clientset kubernetes.Interface) (bool, error) { func RequiresUpgrade(ctx context.Context, kbClient kbclient.Client, newcfg embeddedclusterv1beta1.ConfigSpec) (bool, error) { curcfg, err := ClusterConfig(ctx, kbClient) if err != nil { + // if there is no installation object we can't start an upgrade. this is a valid + // scenario specially during cluster bootstrap. as we do not need to upgrade the + // cluster just after its installation we can return nil here. + // (the cluster in the first kots version will match the cluster installed during bootstrap) + if errors.Is(err, ErrNoInstallations) { + return false, nil + } return false, fmt.Errorf("failed to get current cluster config: %w", err) } serializedCur, err := json.Marshal(curcfg) @@ -71,6 +76,20 @@ func ListInstallations(ctx context.Context, kbClient kbclient.Client) ([]embedde return installationList.Items, nil } +func InstallationSucceeded(ctx context.Context, ins *embeddedclusterv1beta1.Installation) bool { + return ins.Status.State == embeddedclusterv1beta1.InstallationStateInstalled +} + +func InstallationFailed(ctx context.Context, ins *embeddedclusterv1beta1.Installation) bool { + switch ins.Status.State { + case embeddedclusterv1beta1.InstallationStateFailed, + embeddedclusterv1beta1.InstallationStateHelmChartUpdateFailure, + embeddedclusterv1beta1.InstallationStateObsolete: + return true + } + return false +} + // ClusterConfig will extract the current cluster configuration from the latest installation // object found in the cluster. func ClusterConfig(ctx context.Context, kbClient kbclient.Client) (*embeddedclusterv1beta1.ConfigSpec, error) { @@ -84,15 +103,15 @@ func ClusterConfig(ctx context.Context, kbClient kbclient.Client) (*embeddedclus func GetSeaweedFSS3ServiceIP(ctx context.Context, kbClient kbclient.Client) (string, error) { nsn := k8stypes.NamespacedName{Name: seaweedfsS3SVCName, Namespace: seaweedfsNamespace} var svc corev1.Service - if err := kbClient.Get(ctx, nsn, &svc); err != nil && !errors.IsNotFound(err) { + if err := kbClient.Get(ctx, nsn, &svc); err != nil && !k8serrors.IsNotFound(err) { return "", fmt.Errorf("failed to get seaweedfs s3 service: %w", err) - } else if errors.IsNotFound(err) { + } else if k8serrors.IsNotFound(err) { return "", nil } return svc.Spec.ClusterIP, nil } -func getArtifactsFromInstallation(installation kotsv1beta1.Installation, appSlug string) *embeddedclusterv1beta1.ArtifactsLocation { +func getArtifactsFromInstallation(installation kotsv1beta1.Installation) *embeddedclusterv1beta1.ArtifactsLocation { if installation.Spec.EmbeddedClusterArtifacts == nil { return nil } @@ -105,39 +124,3 @@ func getArtifactsFromInstallation(installation kotsv1beta1.Installation, appSlug AdditionalArtifacts: installation.Spec.EmbeddedClusterArtifacts.AdditionalArtifacts, } } - -// startClusterUpgrade will create a new installation with the provided config. -func startClusterUpgrade(ctx context.Context, newcfg embeddedclusterv1beta1.ConfigSpec, artifacts *embeddedclusterv1beta1.ArtifactsLocation, license kotsv1beta1.License) error { - kbClient, err := k8sutil.GetKubeClient(ctx) - if err != nil { - return fmt.Errorf("failed to get kubeclient: %w", err) - } - current, err := GetCurrentInstallation(ctx, kbClient) - if err != nil { - return fmt.Errorf("failed to get current installation: %w", err) - } - newins := embeddedclusterv1beta1.Installation{ - ObjectMeta: metav1.ObjectMeta{ - Name: time.Now().Format("20060102150405"), - Labels: map[string]string{ - "replicated.com/disaster-recovery": "ec-install", - }, - }, - Spec: embeddedclusterv1beta1.InstallationSpec{ - ClusterID: current.Spec.ClusterID, - MetricsBaseURL: current.Spec.MetricsBaseURL, - HighAvailability: current.Spec.HighAvailability, - AirGap: current.Spec.AirGap, - Network: current.Spec.Network, - Artifacts: artifacts, - Config: &newcfg, - EndUserK0sConfigOverrides: current.Spec.EndUserK0sConfigOverrides, - BinaryName: current.Spec.BinaryName, - LicenseInfo: &embeddedclusterv1beta1.LicenseInfo{IsDisasterRecoverySupported: license.Spec.IsDisasterRecoverySupported}, - }, - } - if err := kbClient.Create(ctx, &newins); err != nil { - return fmt.Errorf("failed to create installation: %w", err) - } - return nil -} diff --git a/pkg/embeddedcluster/util_test.go b/pkg/embeddedcluster/util_test.go index 557a1505e3..aac35fec49 100644 --- a/pkg/embeddedcluster/util_test.go +++ b/pkg/embeddedcluster/util_test.go @@ -11,7 +11,6 @@ import ( func Test_getArtifactsFromInstallation(t *testing.T) { type args struct { installation kotsv1beta1.Installation - appSlug string } tests := []struct { name string @@ -22,7 +21,6 @@ func Test_getArtifactsFromInstallation(t *testing.T) { name: "no artifacts", args: args{ installation: kotsv1beta1.Installation{}, - appSlug: "my-app", }, want: nil, }, @@ -43,7 +41,6 @@ func Test_getArtifactsFromInstallation(t *testing.T) { }, }, }, - appSlug: "my-app", }, want: &embeddedclusterv1beta1.ArtifactsLocation{ Images: "onprem.registry.com/my-app/embedded-cluster/images-amd64.tar:v1", @@ -59,7 +56,7 @@ func Test_getArtifactsFromInstallation(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := getArtifactsFromInstallation(tt.args.installation, tt.args.appSlug) + got := getArtifactsFromInstallation(tt.args.installation) if !reflect.DeepEqual(got, tt.want) { t.Errorf("getArtifactsFromInstallation() = %v, want %v", got, tt.want) } diff --git a/pkg/gitops/gitops.go b/pkg/gitops/gitops.go index 486b2d4459..43a46b09be 100644 --- a/pkg/gitops/gitops.go +++ b/pkg/gitops/gitops.go @@ -20,6 +20,7 @@ import ( go_git_ssh "github.com/go-git/go-git/v5/plumbing/transport/ssh" "github.com/mikesmitty/edkey" "github.com/pkg/errors" + apptypes "github.com/replicatedhq/kots/pkg/app/types" "github.com/replicatedhq/kots/pkg/apparchive" "github.com/replicatedhq/kots/pkg/binaries" "github.com/replicatedhq/kots/pkg/crypto" @@ -712,6 +713,21 @@ func getAuth(privateKey string) (transport.AuthMethod, error) { return auth, nil } +func CreateGitOpsDownstreamCommit(a *apptypes.App, clusterID string, newSequence int, filesInDir string, downstreamName string) (string, error) { + downstreamGitOps, err := GetDownstreamGitOps(a.ID, clusterID) + if err != nil { + return "", errors.Wrap(err, "failed to get downstream gitops") + } + if downstreamGitOps == nil || !downstreamGitOps.IsConnected { + return "", nil + } + createdCommitURL, err := CreateGitOpsCommit(downstreamGitOps, a.Slug, a.Name, int(newSequence), filesInDir, downstreamName) + if err != nil { + return "", errors.Wrap(err, "failed to create gitops commit") + } + return createdCommitURL, nil +} + func CreateGitOpsCommit(gitOpsConfig *GitOpsConfig, appSlug string, appName string, newSequence int, archiveDir string, downstreamName string) (string, error) { out, _, err := apparchive.GetRenderedApp(archiveDir, downstreamName, binaries.GetKustomizeBinPath()) if err != nil { diff --git a/pkg/gitops/types/gitops_interface.go b/pkg/gitops/types/gitops_interface.go deleted file mode 100644 index 65ad808f48..0000000000 --- a/pkg/gitops/types/gitops_interface.go +++ /dev/null @@ -1,5 +0,0 @@ -package types - -type DownstreamGitOps interface { - CreateGitOpsDownstreamCommit(appID string, clusterID string, newSequence int, archiveDir string, downstreamName string) (string, error) -} diff --git a/pkg/handlers/airgap.go b/pkg/handlers/airgap.go index a5be3abcbd..1d0a905d0c 100644 --- a/pkg/handlers/airgap.go +++ b/pkg/handlers/airgap.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "sync" "github.com/gorilla/mux" @@ -19,6 +20,8 @@ import ( "github.com/replicatedhq/kots/pkg/kotsutil" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/store" + "github.com/replicatedhq/kots/pkg/tasks" + "github.com/replicatedhq/kots/pkg/update" "github.com/replicatedhq/kots/pkg/util" ) @@ -322,23 +325,29 @@ func (h *Handler) UpdateAppFromAirgap(w http.ResponseWriter, r *http.Request) { } // this is to avoid a race condition where the UI polls the task status before it is set by the goroutine - if err := store.GetStore().SetTaskStatus("update-download", "Processing...", "running"); err != nil { + if err := tasks.SetTaskStatus("update-download", "Processing...", "running"); err != nil { logger.Error(errors.Wrap(err, "failed to set task status")) w.WriteHeader(http.StatusInternalServerError) return } go func() { - if err := airgap.UpdateAppFromAirgap(a, airgapBundlePath, false, false, false); err != nil { - logger.Error(errors.Wrap(err, "failed to update app from airgap bundle")) - - // if NoRetry is set, we stll want to clean up immediately - cause := errors.Cause(err) - if err, ok := cause.(util.ActionableError); !ok || !err.NoRetry { + if util.IsEmbeddedCluster() { + if err := airgap.UpdateAppFromECBundle(a.Slug, airgapBundlePath); err != nil { + logger.Error(errors.Wrap(err, "failed to update app from ec airgap bundle")) + w.WriteHeader(http.StatusInternalServerError) return } + } else { + if err := airgap.UpdateAppFromAirgap(a, airgapBundlePath, false, false, false); err != nil { + logger.Error(errors.Wrap(err, "failed to update app from airgap bundle")) + // if NoRetry is set, we stll want to clean up immediately + cause := errors.Cause(err) + if err, ok := cause.(util.ActionableError); !ok || !err.NoRetry { + return + } + } } - if err := cleanUp(identifier, totalChunks); err != nil { logger.Error(errors.Wrap(err, "failed to clean up")) } @@ -551,3 +560,85 @@ func (h *Handler) UploadInitialAirgapApp(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusOK) } + +func (h *Handler) UploadAirgapUpdate(w http.ResponseWriter, r *http.Request) { + appSlug := mux.Vars(r)["appSlug"] + + app, err := store.GetStore().GetAppFromSlug(appSlug) + if err != nil { + logger.Error(errors.Wrapf(err, "failed to get app for slug %q", appSlug)) + w.WriteHeader(http.StatusNotFound) + return + } + + contentType := strings.Split(r.Header.Get("Content-Type"), ";")[0] + contentType = strings.TrimSpace(contentType) + + if contentType != "multipart/form-data" { + logger.Error(errors.Errorf("unsupported content type: %s", r.Header.Get("Content-Type"))) + w.WriteHeader(http.StatusBadRequest) + return + } + + if !app.IsAirgap { + logger.Error(errors.New("not an airgap app")) + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("Cannot update an online install using an airgap update")) + return + } + + formReader, err := r.MultipartReader() + if err != nil { + logger.Error(errors.Wrap(err, "failed to get multipart reader")) + w.WriteHeader(http.StatusInternalServerError) + return + } + + foundAirgapUpdate := false + for { + part, err := formReader.NextPart() + if err != nil { + if err == io.EOF { + break + } + logger.Error(errors.Wrap(err, "failed to get next part")) + w.WriteHeader(http.StatusInternalServerError) + return + } + + if part.FormName() != "application.airgap" { + continue + } + + foundAirgapUpdate = true + + tmpFile, err := os.CreateTemp("", "kots-airgap") + if err != nil { + logger.Error(errors.Wrap(err, "failed to create temp file")) + w.WriteHeader(http.StatusInternalServerError) + return + } + defer os.RemoveAll(tmpFile.Name()) + + _, err = io.Copy(tmpFile, part) + if err != nil { + logger.Error(errors.Wrap(err, "failed to copy part data")) + w.WriteHeader(http.StatusInternalServerError) + return + } + + if err := update.RegisterAirgapUpdate(app.Slug, tmpFile.Name()); err != nil { + logger.Error(errors.Wrap(err, "failed to registry airgap update")) + w.WriteHeader(http.StatusInternalServerError) + return + } + } + + if !foundAirgapUpdate { + logger.Error(errors.New("no airgap update found in form data")) + w.WriteHeader(http.StatusBadRequest) + return + } + + JSON(w, http.StatusOK, struct{}{}) +} diff --git a/pkg/handlers/app.go b/pkg/handlers/app.go index 891a4a8490..d351a68a58 100644 --- a/pkg/handlers/app.go +++ b/pkg/handlers/app.go @@ -6,12 +6,9 @@ import ( "fmt" "net/http" "strconv" - "strings" "github.com/gorilla/mux" "github.com/pkg/errors" - embeddedclusterv1beta1 "github.com/replicatedhq/embedded-cluster-kinds/apis/v1beta1" - "github.com/replicatedhq/kots/pkg/airgap" downstreamtypes "github.com/replicatedhq/kots/pkg/api/downstream/types" "github.com/replicatedhq/kots/pkg/api/handlers/types" apptypes "github.com/replicatedhq/kots/pkg/app/types" @@ -25,8 +22,9 @@ import ( "github.com/replicatedhq/kots/pkg/render" "github.com/replicatedhq/kots/pkg/session" "github.com/replicatedhq/kots/pkg/store" - "github.com/replicatedhq/kots/pkg/store/kotsstore" storetypes "github.com/replicatedhq/kots/pkg/store/types" + "github.com/replicatedhq/kots/pkg/tasks" + "github.com/replicatedhq/kots/pkg/update" "github.com/replicatedhq/kots/pkg/util" "github.com/replicatedhq/kots/pkg/version" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" @@ -116,7 +114,7 @@ func (h *Handler) ListApps(w http.ResponseWriter, r *http.Request) { } } - responseApp, err := responseAppFromApp(a) + responseApp, err := responseAppFromApp(r.Context(), a) if err != nil { logger.Error(err) w.WriteHeader(http.StatusInternalServerError) @@ -162,7 +160,7 @@ func (h *Handler) GetApp(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) return } - responseApp, err := responseAppFromApp(a) + responseApp, err := responseAppFromApp(r.Context(), a) if err != nil { logger.Error(err) w.WriteHeader(http.StatusInternalServerError) @@ -172,7 +170,7 @@ func (h *Handler) GetApp(w http.ResponseWriter, r *http.Request) { JSON(w, http.StatusOK, responseApp) } -func responseAppFromApp(a *apptypes.App) (*types.ResponseApp, error) { +func responseAppFromApp(ctx context.Context, a *apptypes.App) (*types.ResponseApp, error) { license, err := store.GetStore().GetLatestLicenseForApp(a.ID) if err != nil { return nil, errors.Wrap(err, "failed to get license") @@ -279,40 +277,17 @@ func responseAppFromApp(a *apptypes.App) (*types.ResponseApp, error) { ID: d.ClusterID, Slug: d.ClusterSlug, } - if util.IsEmbeddedCluster() { - var embeddedClusterConfig *embeddedclusterv1beta1.Config - if appVersions.CurrentVersion != nil { - embeddedClusterConfig, err = store.GetStore().GetEmbeddedClusterConfigForVersion(a.ID, appVersions.CurrentVersion.Sequence) - if err != nil { - return nil, errors.Wrap(err, "failed to get embedded cluster config") - } + kbClient, err := k8sutil.GetKubeClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get kubeclient: %w", err) } - - if embeddedClusterConfig != nil { - kbClient, err := k8sutil.GetKubeClient(context.TODO()) - if err != nil { - return nil, fmt.Errorf("failed to get kubeclient: %w", err) - } - - cluster.RequiresUpgrade, err = embeddedcluster.RequiresUpgrade(context.TODO(), kbClient, embeddedClusterConfig.Spec) - if err != nil { - return nil, errors.Wrap(err, "failed to check if cluster requires upgrade") - } - - embeddedClusterInstallations, err := embeddedcluster.ListInstallations(context.TODO(), kbClient) - if err != nil { - return nil, errors.Wrap(err, "failed to list installations") - } - cluster.NumInstallations = len(embeddedClusterInstallations) - - currentInstallation, err := embeddedcluster.GetCurrentInstallation(context.TODO(), kbClient) - if err != nil { - return nil, errors.Wrap(err, "failed to get latest installation") - } - if currentInstallation != nil { - cluster.State = string(currentInstallation.Status.State) - } + currentInstallation, err := embeddedcluster.GetCurrentInstallation(ctx, kbClient) + if err != nil { + return nil, errors.Wrap(err, "failed to get latest installation") + } + if currentInstallation != nil { + cluster.State = string(currentInstallation.Status.State) } } @@ -592,16 +567,16 @@ func (h *Handler) CanInstallAppVersion(w http.ResponseWriter, r *http.Request) { return } - missingPrereqs, err := airgap.GetMissingRequiredVersions(a, decoded.(*kotsv1beta1.Airgap)) + deployable, nonDeployableCause, err := update.IsAirgapUpdateDeployable(a, decoded.(*kotsv1beta1.Airgap)) if err != nil { - response.Error = "failed to get release prerequisites" + response.Error = "failed to check if airgap update is deployable" logger.Error(errors.Wrap(err, response.Error)) JSON(w, http.StatusInternalServerError, response) return } - if len(missingPrereqs) > 0 { - response.Error = fmt.Sprintf("This airgap bundle cannot be uploaded because versions %s are required and must be uploaded first.", strings.Join(missingPrereqs, ", ")) + if !deployable { + response.Error = nonDeployableCause JSON(w, http.StatusOK, response) return } @@ -665,13 +640,13 @@ func (h *Handler) GetLatestDeployableVersion(w http.ResponseWriter, r *http.Requ } func (h *Handler) GetAutomatedInstallStatus(w http.ResponseWriter, r *http.Request) { - status, msg, err := store.GetStore().GetTaskStatus(fmt.Sprintf("automated-install-slug-%s", mux.Vars(r)["appSlug"])) + status, msg, err := tasks.GetTaskStatus(fmt.Sprintf("automated-install-slug-%s", mux.Vars(r)["appSlug"])) if err != nil { logger.Error(errors.Wrapf(err, "failed to get install status for app %s", mux.Vars(r)["appSlug"])) w.WriteHeader(http.StatusInternalServerError) return } - response := kotsstore.TaskStatus{ + response := tasks.TaskStatus{ Status: status, Message: msg, } diff --git a/pkg/handlers/config.go b/pkg/handlers/config.go index 95b9c1ab4b..f33fda2ceb 100644 --- a/pkg/handlers/config.go +++ b/pkg/handlers/config.go @@ -17,7 +17,6 @@ import ( apptypes "github.com/replicatedhq/kots/pkg/app/types" "github.com/replicatedhq/kots/pkg/config" kotsconfig "github.com/replicatedhq/kots/pkg/config" - "github.com/replicatedhq/kots/pkg/crypto" kotsadmconfig "github.com/replicatedhq/kots/pkg/kotsadmconfig" configtypes "github.com/replicatedhq/kots/pkg/kotsadmconfig/types" configvalidation "github.com/replicatedhq/kots/pkg/kotsadmconfig/validation" @@ -28,6 +27,7 @@ import ( registrytypes "github.com/replicatedhq/kots/pkg/registry/types" "github.com/replicatedhq/kots/pkg/render" rendertypes "github.com/replicatedhq/kots/pkg/render/types" + "github.com/replicatedhq/kots/pkg/reporting" "github.com/replicatedhq/kots/pkg/store" storetypes "github.com/replicatedhq/kots/pkg/store/types" "github.com/replicatedhq/kots/pkg/template" @@ -156,6 +156,21 @@ func (h *Handler) UpdateAppConfig(w http.ResponseWriter, r *http.Request) { return } + isEditable, err := isVersionConfigEditable(foundApp, updateAppConfigRequest.Sequence) + if err != nil { + updateAppConfigResponse.Error = "failed to check if version is editable" + logger.Error(errors.Wrap(err, updateAppConfigResponse.Error)) + JSON(w, http.StatusInternalServerError, updateAppConfigResponse) + return + } + + if !isEditable { + updateAppConfigResponse.Error = "this version cannot be edited" + logger.Error(errors.Wrap(err, updateAppConfigResponse.Error)) + JSON(w, http.StatusForbidden, updateAppConfigResponse) + return + } + validationErrors, err := configvalidation.ValidateConfigSpec(kotsv1beta1.ConfigSpec{Groups: updateAppConfigRequest.ConfigGroups}) if err != nil { updateAppConfigResponse.Error = "failed to validate config spec." @@ -532,6 +547,30 @@ func (h *Handler) CurrentAppConfig(w http.ResponseWriter, r *http.Request) { JSON(w, http.StatusOK, currentAppConfigResponse) } +func isVersionConfigEditable(app *apptypes.App, sequence int64) (bool, error) { + if !util.IsEmbeddedCluster() { + return true, nil + } + // in embedded cluster, past versions cannot be edited + downstreams, err := store.GetStore().ListDownstreamsForApp(app.ID) + if err != nil { + return false, errors.Wrap(err, "failed to list downstreams for app") + } + if len(downstreams) == 0 { + return false, errors.New("no downstreams found for app") + } + versions, err := store.GetStore().GetDownstreamVersions(app.ID, downstreams[0].ClusterID, true) + if err != nil { + return false, errors.Wrap(err, "failed to get downstream versions") + } + for _, v := range versions.PastVersions { + if v.Sequence == sequence { + return false, nil + } + } + return true, nil +} + func shouldCreateNewAppVersion(archiveDir string, appID string, sequence int64) (bool, error) { // Updates are allowed for any version that does not have base rendered. if _, err := os.Stat(filepath.Join(archiveDir, "base")); err != nil { @@ -611,7 +650,7 @@ func updateAppConfig(updateApp *apptypes.App, sequence int64, configGroups []kot return updateAppConfigResponse, err } - requiredItems, requiredItemsTitles := getMissingRequiredConfig(configGroups) + requiredItems, requiredItemsTitles := kotsadmconfig.GetMissingRequiredConfig(configGroups) // not having all the required items is only a failure for the version that the user intended to edit if len(requiredItems) > 0 && isPrimaryVersion { @@ -624,7 +663,7 @@ func updateAppConfig(updateApp *apptypes.App, sequence int64, configGroups []kot // so we don't need the complex logic in kots, we can just write if kotsKinds.ConfigValues != nil { values := kotsKinds.ConfigValues.Spec.Values - kotsKinds.ConfigValues.Spec.Values = updateAppConfigValues(values, configGroups) + kotsKinds.ConfigValues.Spec.Values = kotsadmconfig.UpdateAppConfigValues(values, configGroups) configValuesSpec, err := kotsKinds.Marshal("kots.io", "v1beta1", "ConfigValues") if err != nil { @@ -697,6 +736,7 @@ func updateAppConfig(updateApp *apptypes.App, sequence int64, configGroups []kot Downstreams: downstreams, RegistrySettings: registrySettings, Sequence: renderSequence, + ReportingInfo: reporting.GetReportingInfo(app.ID), }) if err != nil { cause := errors.Cause(err) @@ -709,7 +749,7 @@ func updateAppConfig(updateApp *apptypes.App, sequence int64, configGroups []kot } if createNewVersion { - newSequence, err := store.GetStore().CreateAppVersion(updateApp.ID, &sequence, archiveDir, "Config Change", skipPreflights, &version.DownstreamGitOps{}, render.Renderer{}) + newSequence, err := store.GetStore().CreateAppVersion(updateApp.ID, &sequence, archiveDir, "Config Change", skipPreflights, render.Renderer{}) if err != nil { updateAppConfigResponse.Error = "failed to create an app version" return updateAppConfigResponse, err @@ -721,7 +761,7 @@ func updateAppConfig(updateApp *apptypes.App, sequence int64, configGroups []kot updateAppConfigResponse.Error = "failed to get existing downstream version source" return updateAppConfigResponse, err } - if err := store.GetStore().UpdateAppVersion(updateApp.ID, sequence, nil, archiveDir, source, skipPreflights, &version.DownstreamGitOps{}, render.Renderer{}); err != nil { + if err := store.GetStore().UpdateAppVersion(updateApp.ID, sequence, nil, archiveDir, source, skipPreflights, render.Renderer{}); err != nil { updateAppConfigResponse.Error = "failed to update app version" return updateAppConfigResponse, err } @@ -761,76 +801,6 @@ func updateAppConfig(updateApp *apptypes.App, sequence int64, configGroups []kot return updateAppConfigResponse, nil } -func getMissingRequiredConfig(configGroups []kotsv1beta1.ConfigGroup) ([]string, []string) { - requiredItems := make([]string, 0, 0) - requiredItemsTitles := make([]string, 0, 0) - for _, group := range configGroups { - if group.When == "false" { - continue - } - for _, item := range group.Items { - if kotsadmconfig.IsRequiredItem(item) && kotsadmconfig.IsUnsetItem(item) { - requiredItems = append(requiredItems, item.Name) - if item.Title != "" { - requiredItemsTitles = append(requiredItemsTitles, item.Title) - } else { - requiredItemsTitles = append(requiredItemsTitles, item.Name) - } - } - } - } - - return requiredItems, requiredItemsTitles -} - -func updateAppConfigValues(values map[string]kotsv1beta1.ConfigValue, configGroups []kotsv1beta1.ConfigGroup) map[string]kotsv1beta1.ConfigValue { - for _, group := range configGroups { - for _, item := range group.Items { - if item.Type == "file" { - v := values[item.Name] - v.Filename = item.Filename - values[item.Name] = v - } - if item.Value.Type == multitype.Bool { - updatedValue := item.Value.BoolVal - v := values[item.Name] - v.Value = strconv.FormatBool(updatedValue) - values[item.Name] = v - } else if item.Value.Type == multitype.String { - updatedValue := item.Value.String() - if item.Type == "password" { - // encrypt using the key - // if the decryption succeeds, don't encrypt again - _, err := util.DecryptConfigValue(updatedValue) - if err != nil { - updatedValue = base64.StdEncoding.EncodeToString(crypto.Encrypt([]byte(updatedValue))) - } - } - - v := values[item.Name] - v.Value = updatedValue - values[item.Name] = v - } - for _, repeatableValues := range item.ValuesByGroup { - // clear out all variadic values for this group first - for name, value := range values { - if value.RepeatableItem == item.Name { - delete(values, name) - } - } - // add variadic groups back in declaratively - for itemName, valueItem := range repeatableValues { - v := values[itemName] - v.Value = fmt.Sprintf("%v", valueItem) - v.RepeatableItem = item.Name - values[itemName] = v - } - } - } - } - return values -} - type SetAppConfigValuesRequest struct { ConfigValues []byte `json:"configValues"` Merge bool `json:"merge"` diff --git a/pkg/handlers/config_test.go b/pkg/handlers/config_test.go index 7e67c6147e..c7d93f7f23 100644 --- a/pkg/handlers/config_test.go +++ b/pkg/handlers/config_test.go @@ -9,79 +9,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -func Test_updateAppConfigValues(t *testing.T) { - tests := []struct { - name string - values map[string]kotsv1beta1.ConfigValue - configGroups []kotsv1beta1.ConfigGroup - want map[string]kotsv1beta1.ConfigValue - }{ - { - name: "update config values", - values: map[string]kotsv1beta1.ConfigValue{ - "secretName-1": { - Value: "111", - RepeatableItem: "secretName", - }, - "secretName-2": { - Value: "456", - RepeatableItem: "secretName", - }, - "podName": { - Value: "test-pod", - }, - }, - configGroups: []kotsv1beta1.ConfigGroup{ - { - Name: "secret", - Items: []kotsv1beta1.ConfigItem{ - { - Name: "secretName", - ValuesByGroup: kotsv1beta1.ValuesByGroup{ - "Secrets": { - "secretName-1": "123", - "secretName-2": "456", - }, - }, - }, - }, - }, - { - Name: "pod", - Items: []kotsv1beta1.ConfigItem{ - { - Name: "podName", - Value: multitype.BoolOrString{Type: 0, StrVal: "real-pod"}, - }, - }, - }, - }, - want: map[string]kotsv1beta1.ConfigValue{ - "podName": { - Value: "real-pod", - }, - "secretName": {}, - "secretName-1": { - Value: "123", - RepeatableItem: "secretName", - }, - "secretName-2": { - Value: "456", - RepeatableItem: "secretName", - }, - }, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - req := require.New(t) - updatedValues := updateAppConfigValues(test.values, test.configGroups) - - req.Equal(test.want, updatedValues) - }) - } -} - func Test_mergeConfigValues(t *testing.T) { tests := []struct { name string diff --git a/pkg/handlers/dashboard.go b/pkg/handlers/dashboard.go index 6f6da0f0df..f3c0b3ec0a 100644 --- a/pkg/handlers/dashboard.go +++ b/pkg/handlers/dashboard.go @@ -7,8 +7,11 @@ import ( "github.com/gorilla/mux" "github.com/pkg/errors" appstatetypes "github.com/replicatedhq/kots/pkg/appstate/types" + "github.com/replicatedhq/kots/pkg/embeddedcluster" + "github.com/replicatedhq/kots/pkg/k8sutil" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/store" + "github.com/replicatedhq/kots/pkg/util" "github.com/replicatedhq/kots/pkg/version" ) @@ -37,13 +40,6 @@ func (h *Handler) GetAppDashboard(w http.ResponseWriter, r *http.Request) { return } - ecState, err := store.GetStore().GetEmbeddedClusterState() - if err != nil { - logger.Error(err) - w.WriteHeader(500) - return - } - parentSequence, err := store.GetStore().GetCurrentParentSequence(a.ID, clusterID) if err != nil { logger.Error(err) @@ -70,11 +66,28 @@ func (h *Handler) GetAppDashboard(w http.ResponseWriter, r *http.Request) { metrics = version.GetMetricCharts(graphs, prometheusAddress) } + embeddedClusterState := "" + if util.IsEmbeddedCluster() { + kbClient, err := k8sutil.GetKubeClient(r.Context()) + if err != nil { + logger.Error(err) + w.WriteHeader(500) + return + } + ecInstallation, err := embeddedcluster.GetCurrentInstallation(r.Context(), kbClient) + if err != nil { + logger.Error(err) + w.WriteHeader(500) + return + } + embeddedClusterState = ecInstallation.Status.State + } + getAppDashboardResponse := GetAppDashboardResponse{ AppStatus: appStatus, Metrics: metrics, PrometheusAddress: prometheusAddress, - EmbeddedClusterState: ecState, + EmbeddedClusterState: embeddedClusterState, } JSON(w, 200, getAppDashboardResponse) diff --git a/pkg/handlers/gitops.go b/pkg/handlers/gitops.go index b254789f9b..0d946a7c99 100644 --- a/pkg/handlers/gitops.go +++ b/pkg/handlers/gitops.go @@ -13,6 +13,7 @@ import ( "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/reporting" "github.com/replicatedhq/kots/pkg/store" + "github.com/replicatedhq/kots/pkg/tasks" ) type UpdateAppGitOpsRequest struct { @@ -103,7 +104,7 @@ func (h *Handler) DisableAppGitOps(w http.ResponseWriter, r *http.Request) { } func (h *Handler) InitGitOpsConnection(w http.ResponseWriter, r *http.Request) { - currentStatus, _, err := store.GetStore().GetTaskStatus("gitops-init") + currentStatus, _, err := tasks.GetTaskStatus("gitops-init") if err != nil { logger.Error(err) w.WriteHeader(http.StatusInternalServerError) @@ -187,7 +188,7 @@ func (h *Handler) InitGitOpsConnection(w http.ResponseWriter, r *http.Request) { } go func() { - if err := store.GetStore().SetTaskStatus("gitops-init", "Creating commits ...", "running"); err != nil { + if err := tasks.SetTaskStatus("gitops-init", "Creating commits ...", "running"); err != nil { logger.Error(errors.Wrap(err, "failed to set task status running")) return } @@ -195,11 +196,11 @@ func (h *Handler) InitGitOpsConnection(w http.ResponseWriter, r *http.Request) { var finalError error defer func() { if finalError == nil { - if err := store.GetStore().ClearTaskStatus("gitops-init"); err != nil { + if err := tasks.ClearTaskStatus("gitops-init"); err != nil { logger.Error(errors.Wrap(err, "failed to clear task status")) } } else { - if err := store.GetStore().SetTaskStatus("gitops-init", finalError.Error(), "failed"); err != nil { + if err := tasks.SetTaskStatus("gitops-init", finalError.Error(), "failed"); err != nil { logger.Error(errors.Wrap(err, "failed to set task status error")) } } diff --git a/pkg/handlers/handlers.go b/pkg/handlers/handlers.go index 8e0a75eacc..0f368a594a 100644 --- a/pkg/handlers/handlers.go +++ b/pkg/handlers/handlers.go @@ -9,6 +9,7 @@ import ( "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/policy" "github.com/replicatedhq/kots/pkg/store" + "github.com/replicatedhq/kots/pkg/upgradeservice" kotsscheme "github.com/replicatedhq/kotskinds/client/kotsclientset/scheme" troubleshootscheme "github.com/replicatedhq/troubleshoot/pkg/client/troubleshootclientset/scheme" yaml "github.com/replicatedhq/yaml/v3" @@ -109,6 +110,8 @@ func RegisterSessionAuthRoutes(r *mux.Router, kotsStore store.Store, handler KOT HandlerFunc(middleware.EnforceAccess(policy.AppRead, handler.GetLatestDeployableVersion)) r.Name("GetUpdateDownloadStatus").Path("/api/v1/app/{appSlug}/task/updatedownload").Methods("GET"). HandlerFunc(middleware.EnforceAccess(policy.AppRead, handler.GetUpdateDownloadStatus)) // NOTE: appSlug is unused + r.Name("GetAvailableUpdates").Path("/api/v1/app/{appSlug}/updates").Methods("GET"). + HandlerFunc(middleware.EnforceAccess(policy.AppDownstreamRead, handler.GetAvailableUpdates)) // Airgap r.Name("AirgapBundleProgress").Path("/api/v1/app/{appSlug}/airgap/bundleprogress/{identifier}/{totalChunks}").Methods("GET"). @@ -129,6 +132,8 @@ func RegisterSessionAuthRoutes(r *mux.Router, kotsStore store.Store, handler KOT HandlerFunc(middleware.EnforceAccess(policy.AppDownstreamWrite, handler.ResetAirgapInstallStatus)) r.Name("GetAirgapUploadConfig").Path("/api/v1/app/{appSlug}/airgap/config").Methods("GET"). HandlerFunc(middleware.EnforceAccess(policy.AppDownstreamWrite, handler.GetAirgapUploadConfig)) + r.Name("UploadAirgapUpdate").Path("/api/v1/app/{appSlug}/airgap/update").Methods("PUT"). + HandlerFunc(middleware.EnforceAccess(policy.AppDownstreamWrite, handler.UploadAirgapUpdate)) // Implemented handlers r.Name("IgnorePreflightRBACErrors").Path("/api/v1/app/{appSlug}/sequence/{sequence}/preflight/ignore-rbac").Methods("POST"). @@ -315,6 +320,17 @@ func RegisterSessionAuthRoutes(r *mux.Router, kotsStore store.Store, handler KOT // Password change r.Name("ChangePassword").Path("/api/v1/password/change").Methods("PUT"). HandlerFunc(middleware.EnforceAccess(policy.PasswordChange, handler.ChangePassword)) + + // Upgrade service + r.Name("StartUpgradeService").Path("/api/v1/app/{appSlug}/start-upgrade-service").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.AppUpdate, handler.StartUpgradeService)) + r.Name("GetUpgradeServiceStatus").Path("/api/v1/app/{appSlug}/task/upgrade-service").Methods("GET"). + HandlerFunc(middleware.EnforceAccess(policy.AppUpdate, handler.GetUpgradeServiceStatus)) + + // Proxy upgrade service requests to the upgrade service + // CAUTION: modifying this route WILL break backwards compatibility + r.Name("UpgradeServiceProxy").PathPrefix("/api/v1/upgrade-service/app/{appSlug}").Methods("GET", "POST", "PUT"). + HandlerFunc(middleware.EnforceAccess(policy.AppUpdate, upgradeservice.Proxy)) } func JSON(w http.ResponseWriter, code int, payload interface{}) { diff --git a/pkg/handlers/handlers_test.go b/pkg/handlers/handlers_test.go index 6af55e1e26..384330f919 100644 --- a/pkg/handlers/handlers_test.go +++ b/pkg/handlers/handlers_test.go @@ -2,8 +2,10 @@ package handlers_test import ( "fmt" + "log" "net/http" "net/http/httptest" + "os" "testing" "time" @@ -24,6 +26,18 @@ import ( "github.com/stretchr/testify/require" ) +func TestMain(m *testing.M) { + behavior := os.Getenv("MOCK_BEHAVIOR") + switch behavior { + case "": + os.Exit(m.Run()) + case "upgrade-service-cmd": + mockUpgradeServiceCmd() + default: + log.Fatalf("unknown behavior %q", behavior) + } +} + var HandlerPolicyTests = map[string][]HandlerPolicyTest{ // Installation "UploadNewLicense": { @@ -398,6 +412,17 @@ var HandlerPolicyTests = map[string][]HandlerPolicyTest{ ExpectStatus: http.StatusOK, }, }, + "GetAvailableUpdates": { + { + Vars: map[string]string{"appSlug": "my-app"}, + Roles: []rbactypes.Role{rbac.ClusterAdminRole}, + SessionRoles: []string{rbac.ClusterAdminRoleID}, + Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { + handlerRecorder.GetAvailableUpdates(gomock.Any(), gomock.Any()) + }, + ExpectStatus: http.StatusOK, + }, + }, // Airgap "AirgapBundleProgress": { @@ -499,6 +524,17 @@ var HandlerPolicyTests = map[string][]HandlerPolicyTest{ ExpectStatus: http.StatusOK, }, }, + "UploadAirgapUpdate": { + { + Vars: map[string]string{"appSlug": "my-app"}, + Roles: []rbactypes.Role{rbac.ClusterAdminRole}, + SessionRoles: []string{rbac.ClusterAdminRoleID}, + Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { + handlerRecorder.UploadAirgapUpdate(gomock.Any(), gomock.Any()) + }, + ExpectStatus: http.StatusOK, + }, + }, // Implemented handlers "IgnorePreflightRBACErrors": { @@ -1393,6 +1429,31 @@ var HandlerPolicyTests = map[string][]HandlerPolicyTest{ ExpectStatus: http.StatusOK, }, }, + + // Upgrade Service + "StartUpgradeService": { + { + Vars: map[string]string{"appSlug": "my-app"}, + Roles: []rbactypes.Role{rbac.ClusterAdminRole}, + SessionRoles: []string{rbac.ClusterAdminRoleID}, + Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { + handlerRecorder.StartUpgradeService(gomock.Any(), gomock.Any()) + }, + ExpectStatus: http.StatusOK, + }, + }, + "GetUpgradeServiceStatus": { + { + Vars: map[string]string{"appSlug": "my-app"}, + Roles: []rbactypes.Role{rbac.ClusterAdminRole}, + SessionRoles: []string{rbac.ClusterAdminRoleID}, + Calls: func(storeRecorder *mock_store.MockStoreMockRecorder, handlerRecorder *mock_handlers.MockKOTSHandlerMockRecorder) { + handlerRecorder.GetUpgradeServiceStatus(gomock.Any(), gomock.Any()) + }, + ExpectStatus: http.StatusOK, + }, + }, + "UpgradeServiceProxy": {}, // Not implemented } type HandlerPolicyTest struct { diff --git a/pkg/handlers/identity.go b/pkg/handlers/identity.go index efdfbc9b3f..2c6bc45532 100644 --- a/pkg/handlers/identity.go +++ b/pkg/handlers/identity.go @@ -25,9 +25,9 @@ import ( "github.com/replicatedhq/kots/pkg/rbac" "github.com/replicatedhq/kots/pkg/render" rendertypes "github.com/replicatedhq/kots/pkg/render/types" + "github.com/replicatedhq/kots/pkg/reporting" "github.com/replicatedhq/kots/pkg/store" "github.com/replicatedhq/kots/pkg/util" - "github.com/replicatedhq/kots/pkg/version" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" @@ -461,6 +461,7 @@ func (h *Handler) ConfigureAppIdentityService(w http.ResponseWriter, r *http.Req Downstreams: downstreams, RegistrySettings: registrySettings, Sequence: nextAppSequence, + ReportingInfo: reporting.GetReportingInfo(a.ID), }) if err != nil { err = errors.Wrap(err, "failed to render archive directory") @@ -469,7 +470,7 @@ func (h *Handler) ConfigureAppIdentityService(w http.ResponseWriter, r *http.Req return } - newSequence, err := store.GetStore().CreateAppVersion(a.ID, &latestSequence, archiveDir, "Identity Service", false, &version.DownstreamGitOps{}, render.Renderer{}) + newSequence, err := store.GetStore().CreateAppVersion(a.ID, &latestSequence, archiveDir, "Identity Service", false, render.Renderer{}) if err != nil { err = errors.Wrap(err, "failed to create an app version") logger.Error(err) diff --git a/pkg/handlers/image_rewrite_status.go b/pkg/handlers/image_rewrite_status.go index fe8cba3e6a..611eb178a4 100644 --- a/pkg/handlers/image_rewrite_status.go +++ b/pkg/handlers/image_rewrite_status.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/replicatedhq/kots/pkg/logger" - "github.com/replicatedhq/kots/pkg/store" + "github.com/replicatedhq/kots/pkg/tasks" ) type GetImageRewriteStatusResponse struct { @@ -13,7 +13,7 @@ type GetImageRewriteStatusResponse struct { } func (h *Handler) GetImageRewriteStatus(w http.ResponseWriter, r *http.Request) { - status, message, err := store.GetStore().GetTaskStatus("image-rewrite") + status, message, err := tasks.GetTaskStatus("image-rewrite") if err != nil { logger.Error(err) w.WriteHeader(500) diff --git a/pkg/handlers/interface.go b/pkg/handlers/interface.go index 6e10c76a5a..fd5f36e779 100644 --- a/pkg/handlers/interface.go +++ b/pkg/handlers/interface.go @@ -49,6 +49,7 @@ type KOTSHandler interface { GetLatestDeployableVersion(w http.ResponseWriter, r *http.Request) GetUpdateDownloadStatus(w http.ResponseWriter, r *http.Request) // NOTE: appSlug is unused GetPendingApp(w http.ResponseWriter, r *http.Request) + GetAvailableUpdates(w http.ResponseWriter, r *http.Request) // Airgap AirgapBundleProgress(w http.ResponseWriter, r *http.Request) @@ -60,6 +61,7 @@ type KOTSHandler interface { GetAirgapInstallStatus(w http.ResponseWriter, r *http.Request) ResetAirgapInstallStatus(w http.ResponseWriter, r *http.Request) GetAirgapUploadConfig(w http.ResponseWriter, r *http.Request) + UploadAirgapUpdate(w http.ResponseWriter, r *http.Request) // Implemented handlers IgnorePreflightRBACErrors(w http.ResponseWriter, r *http.Request) @@ -161,4 +163,8 @@ type KOTSHandler interface { // Password change ChangePassword(w http.ResponseWriter, r *http.Request) + + // Upgrade service + StartUpgradeService(w http.ResponseWriter, r *http.Request) + GetUpgradeServiceStatus(w http.ResponseWriter, r *http.Request) } diff --git a/pkg/handlers/license.go b/pkg/handlers/license.go index 6f9cf40723..017369f643 100644 --- a/pkg/handlers/license.go +++ b/pkg/handlers/license.go @@ -15,7 +15,7 @@ import ( "github.com/replicatedhq/kots/pkg/k8sutil" "github.com/replicatedhq/kots/pkg/kotsadm" kotsadmtypes "github.com/replicatedhq/kots/pkg/kotsadm/types" - license "github.com/replicatedhq/kots/pkg/kotsadmlicense" + kotsadmlicense "github.com/replicatedhq/kots/pkg/kotsadmlicense" "github.com/replicatedhq/kots/pkg/kotsutil" kotslicense "github.com/replicatedhq/kots/pkg/license" "github.com/replicatedhq/kots/pkg/logger" @@ -24,6 +24,7 @@ import ( "github.com/replicatedhq/kots/pkg/replicatedapp" "github.com/replicatedhq/kots/pkg/store" "github.com/replicatedhq/kots/pkg/updatechecker" + updatecheckertypes "github.com/replicatedhq/kots/pkg/updatechecker/types" "github.com/replicatedhq/kots/pkg/util" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" serializer "k8s.io/apimachinery/pkg/runtime/serializer/json" @@ -135,7 +136,7 @@ func (h *Handler) SyncLicense(w http.ResponseWriter, r *http.Request) { return } - latestLicense, isSynced, err := license.Sync(foundApp, syncLicenseRequest.LicenseData, true) + latestLicense, isSynced, err := kotsadmlicense.Sync(foundApp, syncLicenseRequest.LicenseData, true) if err != nil { syncLicenseResponse.Error = "failed to sync license" logger.Error(errors.Wrap(err, syncLicenseResponse.Error)) @@ -146,7 +147,7 @@ func (h *Handler) SyncLicense(w http.ResponseWriter, r *http.Request) { if !foundApp.IsAirgap && currentLicense.Spec.ChannelID != latestLicense.Spec.ChannelID { // channel changed and this is an online installation, fetch the latest release for the new channel go func(appID string) { - opts := updatechecker.CheckForUpdatesOpts{ + opts := updatecheckertypes.CheckForUpdatesOpts{ AppID: appID, } _, err := updatechecker.CheckForUpdates(opts) @@ -300,7 +301,7 @@ func (h *Handler) UploadNewLicense(w http.ResponseWriter, r *http.Request) { } // check if license already exists - existingLicense, err := license.CheckIfLicenseExists([]byte(licenseString)) + existingLicense, err := kotsadmlicense.CheckIfLicenseExists([]byte(licenseString)) if err != nil { logger.Error(errors.Wrap(err, "failed to check if license already exists")) uploadLicenseResponse.Error = err.Error() @@ -309,7 +310,7 @@ func (h *Handler) UploadNewLicense(w http.ResponseWriter, r *http.Request) { } if existingLicense != nil { - resolved, err := kotslicense.ResolveExistingLicense(verifiedLicense) + resolved, err := kotsadmlicense.ResolveExistingLicense(verifiedLicense) if err != nil { logger.Error(errors.Wrap(err, "failed to resolve existing license conflict")) } @@ -620,7 +621,7 @@ func (h *Handler) ChangeLicense(w http.ResponseWriter, r *http.Request) { return } - newLicense, err := license.Change(foundApp, changeLicenseRequest.LicenseData) + newLicense, err := kotsadmlicense.Change(foundApp, changeLicenseRequest.LicenseData) if err != nil { logger.Error(errors.Wrap(err, "failed to change license")) changeLicenseResponse.Error = errors.Cause(err).Error() @@ -631,7 +632,7 @@ func (h *Handler) ChangeLicense(w http.ResponseWriter, r *http.Request) { if !foundApp.IsAirgap && currentLicense.Spec.ChannelID != newLicense.Spec.ChannelID { // channel changed and this is an online installation, fetch the latest release for the new channel go func(appID string) { - opts := updatechecker.CheckForUpdatesOpts{ + opts := updatecheckertypes.CheckForUpdatesOpts{ AppID: appID, } _, err := updatechecker.CheckForUpdates(opts) diff --git a/pkg/handlers/mock/mock.go b/pkg/handlers/mock/mock.go index 0eb87eb88c..7926488c29 100644 --- a/pkg/handlers/mock/mock.go +++ b/pkg/handlers/mock/mock.go @@ -670,6 +670,18 @@ func (mr *MockKOTSHandlerMockRecorder) GetAutomaticUpdatesConfig(w, r interface{ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAutomaticUpdatesConfig", reflect.TypeOf((*MockKOTSHandler)(nil).GetAutomaticUpdatesConfig), w, r) } +// GetAvailableUpdates mocks base method. +func (m *MockKOTSHandler) GetAvailableUpdates(w http.ResponseWriter, r *http.Request) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "GetAvailableUpdates", w, r) +} + +// GetAvailableUpdates indicates an expected call of GetAvailableUpdates. +func (mr *MockKOTSHandlerMockRecorder) GetAvailableUpdates(w, r interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAvailableUpdates", reflect.TypeOf((*MockKOTSHandler)(nil).GetAvailableUpdates), w, r) +} + // GetBackup mocks base method. func (m *MockKOTSHandler) GetBackup(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() @@ -1054,6 +1066,18 @@ func (mr *MockKOTSHandlerMockRecorder) GetUpdateDownloadStatus(w, r interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUpdateDownloadStatus", reflect.TypeOf((*MockKOTSHandler)(nil).GetUpdateDownloadStatus), w, r) } +// GetUpgradeServiceStatus mocks base method. +func (m *MockKOTSHandler) GetUpgradeServiceStatus(w http.ResponseWriter, r *http.Request) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "GetUpgradeServiceStatus", w, r) +} + +// GetUpgradeServiceStatus indicates an expected call of GetUpgradeServiceStatus. +func (mr *MockKOTSHandlerMockRecorder) GetUpgradeServiceStatus(w, r interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUpgradeServiceStatus", reflect.TypeOf((*MockKOTSHandler)(nil).GetUpgradeServiceStatus), w, r) +} + // GetVeleroStatus mocks base method. func (m *MockKOTSHandler) GetVeleroStatus(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() @@ -1390,6 +1414,18 @@ func (mr *MockKOTSHandlerMockRecorder) StartPreflightChecks(w, r interface{}) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartPreflightChecks", reflect.TypeOf((*MockKOTSHandler)(nil).StartPreflightChecks), w, r) } +// StartUpgradeService mocks base method. +func (m *MockKOTSHandler) StartUpgradeService(w http.ResponseWriter, r *http.Request) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "StartUpgradeService", w, r) +} + +// StartUpgradeService indicates an expected call of StartUpgradeService. +func (mr *MockKOTSHandlerMockRecorder) StartUpgradeService(w, r interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartUpgradeService", reflect.TypeOf((*MockKOTSHandler)(nil).StartUpgradeService), w, r) +} + // SyncLicense mocks base method. func (m *MockKOTSHandler) SyncLicense(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() @@ -1498,6 +1534,18 @@ func (mr *MockKOTSHandlerMockRecorder) UploadAirgapBundleChunk(w, r interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadAirgapBundleChunk", reflect.TypeOf((*MockKOTSHandler)(nil).UploadAirgapBundleChunk), w, r) } +// UploadAirgapUpdate mocks base method. +func (m *MockKOTSHandler) UploadAirgapUpdate(w http.ResponseWriter, r *http.Request) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "UploadAirgapUpdate", w, r) +} + +// UploadAirgapUpdate indicates an expected call of UploadAirgapUpdate. +func (mr *MockKOTSHandlerMockRecorder) UploadAirgapUpdate(w, r interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UploadAirgapUpdate", reflect.TypeOf((*MockKOTSHandler)(nil).UploadAirgapUpdate), w, r) +} + // UploadNewLicense mocks base method. func (m *MockKOTSHandler) UploadNewLicense(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() diff --git a/pkg/handlers/registry.go b/pkg/handlers/registry.go index ca15b7fb15..5890e7b4ea 100644 --- a/pkg/handlers/registry.go +++ b/pkg/handlers/registry.go @@ -22,7 +22,7 @@ import ( registrytypes "github.com/replicatedhq/kots/pkg/registry/types" "github.com/replicatedhq/kots/pkg/render" "github.com/replicatedhq/kots/pkg/store" - "github.com/replicatedhq/kots/pkg/version" + "github.com/replicatedhq/kots/pkg/tasks" corev1 "k8s.io/api/core/v1" "k8s.io/client-go/kubernetes/scheme" ) @@ -132,7 +132,7 @@ func (h *Handler) UpdateAppRegistry(w http.ResponseWriter, r *http.Request) { return } - currentStatus, _, err := store.GetStore().GetTaskStatus("image-rewrite") + currentStatus, _, err := tasks.GetTaskStatus("image-rewrite") if err != nil { logger.Error(errors.Wrap(err, "failed to get image-rewrite taks status")) updateAppRegistryResponse.Error = err.Error() @@ -148,7 +148,7 @@ func (h *Handler) UpdateAppRegistry(w http.ResponseWriter, r *http.Request) { return } - if err := store.GetStore().ClearTaskStatus("image-rewrite"); err != nil { + if err := tasks.ClearTaskStatus("image-rewrite"); err != nil { logger.Error(errors.Wrap(err, "failed to clear image-rewrite taks status")) updateAppRegistryResponse.Error = err.Error() JSON(w, http.StatusInternalServerError, updateAppRegistryResponse) @@ -225,7 +225,7 @@ func (h *Handler) UpdateAppRegistry(w http.ResponseWriter, r *http.Request) { } // set task status before starting the goroutine so that the UI can show the status - if err := store.GetStore().SetTaskStatus("image-rewrite", "Updating registry settings", "running"); err != nil { + if err := tasks.SetTaskStatus("image-rewrite", "Updating registry settings", "running"); err != nil { logger.Error(errors.Wrap(err, "failed to set task status")) updateAppRegistryResponse.Error = err.Error() JSON(w, http.StatusInternalServerError, updateAppRegistryResponse) @@ -257,7 +257,7 @@ func (h *Handler) UpdateAppRegistry(w http.ResponseWriter, r *http.Request) { } defer os.RemoveAll(appDir) - newSequence, err := store.GetStore().CreateAppVersion(foundApp.ID, &latestSequence, appDir, "Registry Change", false, &version.DownstreamGitOps{}, render.Renderer{}) + newSequence, err := store.GetStore().CreateAppVersion(foundApp.ID, &latestSequence, appDir, "Registry Change", false, render.Renderer{}) if err != nil { logger.Error(errors.Wrap(err, "failed to create app version")) return diff --git a/pkg/handlers/status.go b/pkg/handlers/status.go index 94210dd1ac..8d073a45ef 100644 --- a/pkg/handlers/status.go +++ b/pkg/handlers/status.go @@ -8,7 +8,7 @@ import ( "github.com/gorilla/mux" "github.com/pkg/errors" "github.com/replicatedhq/kots/pkg/logger" - "github.com/replicatedhq/kots/pkg/store" + "github.com/replicatedhq/kots/pkg/tasks" ) type GetUpdateDownloadStatusResponse struct { @@ -17,7 +17,7 @@ type GetUpdateDownloadStatusResponse struct { } func (h *Handler) GetUpdateDownloadStatus(w http.ResponseWriter, r *http.Request) { - status, message, err := store.GetStore().GetTaskStatus("update-download") + status, message, err := tasks.GetTaskStatus("update-download") if err != nil { w.WriteHeader(http.StatusInternalServerError) logger.Error(err) @@ -49,7 +49,7 @@ func (h *Handler) GetAppVersionDownloadStatus(w http.ResponseWriter, r *http.Req } taskID := fmt.Sprintf("update-download.%d", sequence) - status, message, err := store.GetStore().GetTaskStatus(taskID) + status, message, err := tasks.GetTaskStatus(taskID) if err != nil { errMsg := fmt.Sprintf("failed to get %s task status", taskID) logger.Error(errors.Wrap(err, errMsg)) diff --git a/pkg/handlers/update.go b/pkg/handlers/update.go index 24aa6a48e8..246b2eb724 100644 --- a/pkg/handlers/update.go +++ b/pkg/handlers/update.go @@ -17,12 +17,17 @@ import ( "github.com/replicatedhq/kots/pkg/airgap" "github.com/replicatedhq/kots/pkg/k8sutil" "github.com/replicatedhq/kots/pkg/kotsadm" + license "github.com/replicatedhq/kots/pkg/kotsadmlicense" + "github.com/replicatedhq/kots/pkg/kotsutil" "github.com/replicatedhq/kots/pkg/kurl" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/reporting" "github.com/replicatedhq/kots/pkg/store" "github.com/replicatedhq/kots/pkg/tasks" + "github.com/replicatedhq/kots/pkg/update" + updatetypes "github.com/replicatedhq/kots/pkg/update/types" "github.com/replicatedhq/kots/pkg/updatechecker" + updatecheckertypes "github.com/replicatedhq/kots/pkg/updatechecker/types" "github.com/replicatedhq/kots/pkg/util" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" ) @@ -63,7 +68,7 @@ func (h *Handler) AppUpdateCheck(w http.ResponseWriter, r *http.Request) { } if contentType == "application/json" { - opts := updatechecker.CheckForUpdatesOpts{ + opts := updatecheckertypes.CheckForUpdatesOpts{ AppID: app.GetID(), DeployLatest: deploy, DeployVersionLabel: deployVersionLabel, @@ -182,7 +187,7 @@ func (h *Handler) AppUpdateCheck(w http.ResponseWriter, r *http.Request) { finishedChan := make(chan error) defer close(finishedChan) - tasks.StartUpdateTaskMonitor("update-download", finishedChan) + tasks.StartTaskMonitor("update-download", finishedChan) err = airgap.UpdateAppFromPath(app, rootDir, "", deploy, skipPreflights, skipCompatibilityCheck) if err != nil { @@ -207,6 +212,69 @@ func (h *Handler) AppUpdateCheck(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) } +type AvailableUpdatesResponse struct { + Success bool `json:"success"` + Updates []updatetypes.AvailableUpdate `json:"updates,omitempty"` +} + +func (h *Handler) GetAvailableUpdates(w http.ResponseWriter, r *http.Request) { + availableUpdatesResponse := AvailableUpdatesResponse{ + Success: false, + } + + appSlug, ok := mux.Vars(r)["appSlug"] + if !ok { + logger.Error(errors.New("appSlug is required")) + JSON(w, http.StatusBadRequest, availableUpdatesResponse) + return + } + + store := store.GetStore() + app, err := store.GetAppFromSlug(appSlug) + if err != nil { + logger.Error(errors.Wrap(err, "failed to get app from slug")) + JSON(w, http.StatusInternalServerError, availableUpdatesResponse) + return + } + + if kotsadm.IsAirgap() { + license, err := kotsutil.LoadLicenseFromBytes([]byte(app.License)) + if err != nil { + logger.Error(errors.Wrap(err, "failed to parse app license")) + JSON(w, http.StatusInternalServerError, availableUpdatesResponse) + return + } + updates, err := update.GetAvailableAirgapUpdates(app, license) + if err != nil { + logger.Error(errors.Wrap(err, "failed to get available airgap updates")) + JSON(w, http.StatusInternalServerError, availableUpdatesResponse) + return + } + availableUpdatesResponse.Success = true + availableUpdatesResponse.Updates = updates + JSON(w, http.StatusOK, availableUpdatesResponse) + return + } + + latestLicense, _, err := license.Sync(app, "", false) + if err != nil { + logger.Error(errors.Wrap(err, "failed to sync license")) + JSON(w, http.StatusInternalServerError, availableUpdatesResponse) + return + } + + updates, err := update.GetAvailableUpdates(store, app, latestLicense) + if err != nil { + logger.Error(errors.Wrap(err, "failed to get available app updates")) + JSON(w, http.StatusInternalServerError, availableUpdatesResponse) + return + } + + availableUpdatesResponse.Success = true + availableUpdatesResponse.Updates = updates + JSON(w, http.StatusOK, availableUpdatesResponse) +} + type UpdateAdminConsoleResponse struct { Success bool `json:"success"` UpdateStatus string `json:"updateStatus"` diff --git a/pkg/handlers/upgrade_service.go b/pkg/handlers/upgrade_service.go new file mode 100644 index 0000000000..2fdf54228a --- /dev/null +++ b/pkg/handlers/upgrade_service.go @@ -0,0 +1,284 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/gorilla/mux" + "github.com/phayes/freeport" + "github.com/pkg/errors" + apptypes "github.com/replicatedhq/kots/pkg/app/types" + "github.com/replicatedhq/kots/pkg/kotsutil" + "github.com/replicatedhq/kots/pkg/logger" + "github.com/replicatedhq/kots/pkg/replicatedapp" + "github.com/replicatedhq/kots/pkg/reporting" + "github.com/replicatedhq/kots/pkg/store" + "github.com/replicatedhq/kots/pkg/tasks" + "github.com/replicatedhq/kots/pkg/update" + "github.com/replicatedhq/kots/pkg/upgradeservice" + upgradeservicetask "github.com/replicatedhq/kots/pkg/upgradeservice/task" + upgradeservicetypes "github.com/replicatedhq/kots/pkg/upgradeservice/types" + "github.com/replicatedhq/kots/pkg/util" +) + +type StartUpgradeServiceRequest struct { + VersionLabel string `json:"versionLabel"` + UpdateCursor string `json:"updateCursor"` + ChannelID string `json:"channelId"` +} + +type StartUpgradeServiceResponse struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +type GetUpgradeServiceStatusResponse struct { + CurrentMessage string `json:"currentMessage"` + Status string `json:"status"` +} + +func (h *Handler) StartUpgradeService(w http.ResponseWriter, r *http.Request) { + response := StartUpgradeServiceResponse{ + Success: false, + } + + request := StartUpgradeServiceRequest{} + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + response.Error = "failed to decode request body" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusBadRequest, response) + return + } + + appSlug := mux.Vars(r)["appSlug"] + + foundApp, err := store.GetStore().GetAppFromSlug(appSlug) + if err != nil { + response.Error = "failed to get app from app slug" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + + canStart, reason, err := canStartUpgradeService(foundApp, request) + if err != nil { + response.Error = "failed to check if upgrade service can start" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + if !canStart { + response.Error = reason + logger.Error(errors.New(response.Error)) + JSON(w, http.StatusBadRequest, response) + return + } + + if err := startUpgradeService(foundApp, request); err != nil { + response.Error = "failed to start upgrade service" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + + response.Success = true + + JSON(w, http.StatusOK, response) +} + +func (h *Handler) GetUpgradeServiceStatus(w http.ResponseWriter, r *http.Request) { + appSlug := mux.Vars(r)["appSlug"] + + status, message, err := upgradeservicetask.GetStatus(appSlug) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + logger.Error(err) + return + } + + JSON(w, http.StatusOK, GetUpgradeServiceStatusResponse{ + CurrentMessage: message, + Status: status, + }) +} + +func canStartUpgradeService(a *apptypes.App, r StartUpgradeServiceRequest) (bool, string, error) { + currLicense, err := kotsutil.LoadLicenseFromBytes([]byte(a.License)) + if err != nil { + return false, "", errors.Wrap(err, "failed to parse app license") + } + + if a.IsAirgap { + updateBundle, err := update.GetAirgapUpdate(a.Slug, r.ChannelID, r.UpdateCursor) + if err != nil { + return false, "", errors.Wrap(err, "failed to get airgap update") + } + airgap, err := kotsutil.FindAirgapMetaInBundle(updateBundle) + if err != nil { + return false, "", errors.Wrap(err, "failed to find airgap metadata") + } + if currLicense.Spec.ChannelID != airgap.Spec.ChannelID || r.ChannelID != airgap.Spec.ChannelID { + return false, "channel mismatch", nil + } + isDeployable, nonDeployableCause, err := update.IsAirgapUpdateDeployable(a, airgap) + if err != nil { + return false, "", errors.Wrap(err, "failed to check if airgap update is deployable") + } + if !isDeployable { + return false, nonDeployableCause, nil + } + return true, "", nil + } + + ll, err := replicatedapp.GetLatestLicense(currLicense) + if err != nil { + return false, "", errors.Wrap(err, "failed to get latest license") + } + if currLicense.Spec.ChannelID != ll.License.Spec.ChannelID || r.ChannelID != ll.License.Spec.ChannelID { + return false, "license channel has changed, please sync the license", nil + } + updates, err := update.GetAvailableUpdates(store.GetStore(), a, currLicense) + if err != nil { + return false, "", errors.Wrap(err, "failed to get available updates") + } + isDeployable, nonDeployableCause := false, "update not found" + for _, u := range updates { + if u.UpdateCursor == r.UpdateCursor { + isDeployable, nonDeployableCause = u.IsDeployable, u.NonDeployableCause + break + } + } + if !isDeployable { + return false, nonDeployableCause, nil + } + return true, "", nil +} + +func startUpgradeService(a *apptypes.App, r StartUpgradeServiceRequest) error { + if err := upgradeservicetask.SetStatusStarting(a.Slug, "Preparing..."); err != nil { + return errors.Wrap(err, "failed to set upgrade service task status") + } + + go func() (finalError error) { + finishedChan := make(chan error) + defer close(finishedChan) + + tasks.StartTaskMonitor(upgradeservicetask.GetID(a.Slug), finishedChan) + defer func() { + if finalError != nil { + logger.Error(finalError) + } + finishedChan <- finalError + }() + + params, err := GetUpgradeServiceParams(store.GetStore(), a, r) + if err != nil { + return err + } + if err := upgradeservice.Start(*params); err != nil { + return errors.Wrap(err, "failed to start upgrade service") + } + return nil + }() + + return nil +} + +func GetUpgradeServiceParams(s store.Store, a *apptypes.App, r StartUpgradeServiceRequest) (*upgradeservicetypes.UpgradeServiceParams, error) { + registrySettings, err := s.GetRegistryDetailsForApp(a.ID) + if err != nil { + return nil, errors.Wrap(err, "failed to get registry details for app") + } + + baseArchive, baseSequence, err := s.GetAppVersionBaseArchive(a.ID, r.VersionLabel) + if err != nil { + return nil, errors.Wrap(err, "failed to get app version base archive") + } + + nextSequence, err := s.GetNextAppSequence(a.ID) + if err != nil { + return nil, errors.Wrap(err, "failed to get next app sequence") + } + + source := "Upstream Update" + if a.IsAirgap { + source = "Airgap Update" + } + + license, err := kotsutil.LoadLicenseFromBytes([]byte(a.License)) + if err != nil { + return nil, errors.Wrap(err, "failed to parse app license") + } + + var updateECVersion string + var updateKOTSBin string + var updateAirgapBundle string + + if a.IsAirgap { + au, err := update.GetAirgapUpdate(a.Slug, r.ChannelID, r.UpdateCursor) + if err != nil { + return nil, errors.Wrap(err, "failed to get airgap update") + } + updateAirgapBundle = au + kb, err := kotsutil.GetKOTSBinFromAirgapBundle(au) + if err != nil { + return nil, errors.Wrap(err, "failed to get kots binary from airgap bundle") + } + updateKOTSBin = kb + ecv, err := kotsutil.GetECVersionFromAirgapBundle(au) + if err != nil { + return nil, errors.Wrap(err, "failed to get kots version from binary") + } + updateECVersion = ecv + } else { + kb, err := replicatedapp.DownloadKOTSBinary(license, r.VersionLabel) + if err != nil { + return nil, errors.Wrap(err, "failed to download kots binary") + } + updateKOTSBin = kb + ecv, err := replicatedapp.GetECVersionForRelease(license, r.VersionLabel) + if err != nil { + return nil, errors.Wrap(err, "failed to get kots version for release") + } + updateECVersion = ecv + } + + port, err := freeport.GetFreePort() + if err != nil { + return nil, errors.Wrap(err, "failed to get free port") + } + + return &upgradeservicetypes.UpgradeServiceParams{ + Port: fmt.Sprintf("%d", port), + + AppID: a.ID, + AppSlug: a.Slug, + AppName: a.Name, + AppIsAirgap: a.IsAirgap, + AppIsGitOps: a.IsGitOps, + AppLicense: a.License, + AppArchive: baseArchive, + + Source: source, + BaseSequence: baseSequence, + NextSequence: nextSequence, + + UpdateVersionLabel: r.VersionLabel, + UpdateCursor: r.UpdateCursor, + UpdateChannelID: r.ChannelID, + UpdateECVersion: updateECVersion, + UpdateKOTSBin: updateKOTSBin, + UpdateAirgapBundle: updateAirgapBundle, + + CurrentECVersion: util.EmbeddedClusterVersion(), + + RegistryEndpoint: registrySettings.Hostname, + RegistryUsername: registrySettings.Username, + RegistryPassword: registrySettings.Password, + RegistryNamespace: registrySettings.Namespace, + RegistryIsReadOnly: registrySettings.IsReadOnly, + + ReportingInfo: reporting.GetReportingInfo(a.ID), + }, nil +} diff --git a/pkg/handlers/upgrade_service_test.go b/pkg/handlers/upgrade_service_test.go new file mode 100644 index 0000000000..97de2268b4 --- /dev/null +++ b/pkg/handlers/upgrade_service_test.go @@ -0,0 +1,415 @@ +package handlers_test + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + apptypes "github.com/replicatedhq/kots/pkg/app/types" + "github.com/replicatedhq/kots/pkg/handlers" + registrytypes "github.com/replicatedhq/kots/pkg/registry/types" + "github.com/replicatedhq/kots/pkg/reporting" + mock_store "github.com/replicatedhq/kots/pkg/store/mock" + "github.com/replicatedhq/kots/pkg/update" + "github.com/replicatedhq/kots/pkg/upgradeservice" + upgradeservicetypes "github.com/replicatedhq/kots/pkg/upgradeservice/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" +) + +func TestStartUpgradeService(t *testing.T) { + // mock replicated app + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/clusterconfig/version/Installer": + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"version": "online-update-ec-version"}) + + case "/clusterconfig/artifact/kots": + kotsTGZ := mockKOTSBinary(t) + w.WriteHeader(http.StatusOK) + w.Write(kotsTGZ) + + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer mockServer.Close() + + // mock update airgap bundle + updateAirgapBundle := mockUpdateAirgapBundle(t) + defer os.Remove(updateAirgapBundle) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockStore := mock_store.NewMockStore(ctrl) + + t.Setenv("USE_MOCK_REPORTING", "1") + t.Setenv("EMBEDDED_CLUSTER_VERSION", "current-ec-version") + t.Setenv("MOCK_BEHAVIOR", "upgrade-service-cmd") + + testLicense := fmt.Sprintf(`apiVersion: kots.io/v1beta1 +kind: License +metadata: + name: testcustomer +spec: + appSlug: my-app + channelID: 1vusIYZLAVxMG6q760OJmRKj5i5 + channelName: My Channel + customerName: Test Customer + endpoint: %s + entitlements: + expires_at: + description: License Expiration + title: Expiration + value: "2030-07-27T00:00:00Z" + valueType: String + isAirgapSupported: true + isGitOpsSupported: true + isSnapshotSupported: true + licenseID: 1vusOokxAVp1tkRGuyxnF23PJcq + licenseSequence: 7 + licenseType: prod + signature: eyJsaWNlbnNlRGF0YSI6ImV5SmhjR2xXWlhKemFXOXVJam9pYTI5MGN5NXBieTkyTVdKbGRHRXhJaXdpYTJsdVpDSTZJa3hwWTJWdWMyVWlMQ0p0WlhSaFpHRjBZU0k2ZXlKdVlXMWxJam9pZEdWemRHTjFjM1J2YldWeUluMHNJbk53WldNaU9uc2liR2xqWlc1elpVbEVJam9pTVhaMWMwOXZhM2hCVm5BeGRHdFNSM1Y1ZUc1R01qTlFTbU54SWl3aWJHbGpaVzV6WlZSNWNHVWlPaUp3Y205a0lpd2lZM1Z6ZEc5dFpYSk9ZVzFsSWpvaVZHVnpkQ0JEZFhOMGIyMWxjaUlzSW1Gd2NGTnNkV2NpT2lKdGVTMWhjSEFpTENKamFHRnVibVZzU1VRaU9pSXhkblZ6U1ZsYVRFRldlRTFITm5FM05qQlBTbTFTUzJvMWFUVWlMQ0pqYUdGdWJtVnNUbUZ0WlNJNklrMTVJRU5vWVc1dVpXd2lMQ0pzYVdObGJuTmxVMlZ4ZFdWdVkyVWlPamNzSW1WdVpIQnZhVzUwSWpvaWFIUjBjSE02THk5eVpYQnNhV05oZEdWa0xtRndjQ0lzSW1WdWRHbDBiR1Z0Wlc1MGN5STZleUppYjI5c1gyWnBaV3hrSWpwN0luUnBkR3hsSWpvaVFtOXZiQ0JHYVdWc1pDSXNJblpoYkhWbElqcDBjblZsTENKMllXeDFaVlI1Y0dVaU9pSkNiMjlzWldGdUluMHNJbVY0Y0dseVpYTmZZWFFpT25zaWRHbDBiR1VpT2lKRmVIQnBjbUYwYVc5dUlpd2laR1Z6WTNKcGNIUnBiMjRpT2lKTWFXTmxibk5sSUVWNGNHbHlZWFJwYjI0aUxDSjJZV3gxWlNJNklqSXdNekF0TURjdE1qZFVNREE2TURBNk1EQmFJaXdpZG1Gc2RXVlVlWEJsSWpvaVUzUnlhVzVuSW4wc0ltaHBaR1JsYmw5bWFXVnNaQ0k2ZXlKMGFYUnNaU0k2SWtocFpHUmxiaUJHYVdWc1pDSXNJblpoYkhWbElqb2lkR2hwY3lCcGN5QnpaV055WlhRaUxDSjJZV3gxWlZSNWNHVWlPaUpUZEhKcGJtY2lMQ0pwYzBocFpHUmxiaUk2ZEhKMVpYMHNJbWx1ZEY5bWFXVnNaQ0k2ZXlKMGFYUnNaU0k2SWtsdWRDQkdhV1ZzWkNJc0luWmhiSFZsSWpveE1qTXNJblpoYkhWbFZIbHdaU0k2SWtsdWRHVm5aWElpZlN3aWMzUnlhVzVuWDJacFpXeGtJanA3SW5ScGRHeGxJam9pVTNSeWFXNW5SbWxsYkdRaUxDSjJZV3gxWlNJNkluTnBibWRzWlNCc2FXNWxJSFJsZUhRaUxDSjJZV3gxWlZSNWNHVWlPaUpUZEhKcGJtY2lmU3dpZEdWNGRGOW1hV1ZzWkNJNmV5SjBhWFJzWlNJNklsUmxlSFFnUm1sbGJHUWlMQ0oyWVd4MVpTSTZJbTExYkhScFhHNXNhVzVsWEc1MFpYaDBJaXdpZG1Gc2RXVlVlWEJsSWpvaVZHVjRkQ0o5ZlN3aWFYTkJhWEpuWVhCVGRYQndiM0owWldRaU9uUnlkV1VzSW1selIybDBUM0J6VTNWd2NHOXlkR1ZrSWpwMGNuVmxMQ0pwYzFOdVlYQnphRzkwVTNWd2NHOXlkR1ZrSWpwMGNuVmxmWDA9IiwiaW5uZXJTaWduYXR1cmUiOiJleUpzYVdObGJuTmxVMmxuYm1GMGRYSmxJam9pYUhneE1XTXZUR1ozUTNoVE5YRmtRWEJGU1hGdVRrMU9NMHBLYTJzNFZHZFhSVVpzVDFKVlJ6UjJjR1YzZEZoV1YzbG1lamRZY0hBd1ExazJZamRyUVRSS2N6TklhR3d3YkZJMFdUQTFMemN2UVVkQ2FEZFZNSGczUkhaTVozUXpVM00wYm5GTFZTdFhXRXBTVHpKWVFVRnZSME4xZFRWR1RGcHJRVWhYY1RSUVFtMXphSFY2Y1ZsdmNucHhlbGhGWVZWVlpFUlVkVXhDTW1nNWFIZ3dXRWhQUmxwUk16bHVkbTlPUjJaT2R5OTRTVmRaZEhSUGRYZHZhMncyTVZsb1JVeFZlRmQxU1ZSRmMwTlVhM2xtTVRNd09IazVSbFJzWlRKeVYyZEVlSEZNYTBSUFNXVXlPRWwzUzJSQkwySXdWVUl5VEZGbVRWcHdWemwyUTNCSkwybHlWek5uYmpaeU5WWjNWMjB2U1dweWJtNDNSelJrVmpadVYzcFRkMGhQUTJSdWEwMTRNRXQ1VVVOa0wxQjFaWEpUYjNSdVEwOXRTMDEzWlRSTGJqaERkMU5YVVRRNGRURkRNbTFpV1VzeGRYTlpOM1YzUFQwaUxDSndkV0pzYVdOTFpYa2lPaUl0TFMwdExVSkZSMGxPSUZCVlFreEpReUJMUlZrdExTMHRMVnh1VFVsSlFrbHFRVTVDWjJ0eGFHdHBSemwzTUVKQlVVVkdRVUZQUTBGUk9FRk5TVWxDUTJkTFEwRlJSVUZ6TkhKdlVIcDFhV1JNZVhOMmIxWTJkemxhTkZ4dVdHRmliME5tWTJNeGFHZFZhQ3N3V1VkS2NFNURSVXhyTjBaTFF5OTJhemR6ZERsR05tY3dUMjlrU0VSbGVYZFJXa2hLZFU1TVpsUnNRbEJHUTJOaU5seHVObTlzVEZOeWNGQTRjbFUzU0d4SGJsRkVSMFJNYVhkS1EyaGtSRGRVVUdSM2FXdHBkMHRGY201aldqaEdaalZsU25vd2RETmlUWFpyVDJaVVluSkJiRnh1WWtGQ1kwbzVNVmxVT1hKdVVXOXFkVWN4UldKUVRqaEZWblI2TWxZNE5IZHViR2Q0TUhCd2JEVjRPSFpOYlhwcE1ISnVibEZVV1VGamJ6WnFhMnBJTTF4dVRuTlVkWE4xUzFkdlJGUjVNWE5yZGtSUk9IbEJZV0ptWTNNME4zWnNRazAwU0RGT1JFNHZSSFJhWWxZdllubDJia0o2YkM4eFZrVnpURmRqWlZWcFRGeHVSWEYxT0VkeWF5dFFVRGQyUkdSd2JFUjNjWFpQV2t4RmRYazNkamhuUm01U09WUlVSV3ByTlVvNWRuWlVTR2RtU25VemVubEVPR2xLWTBSRE5YcHFPVnh1YjFGSlJFRlJRVUpjYmkwdExTMHRSVTVFSUZCVlFreEpReUJMUlZrdExTMHRMVnh1SWl3aWEyVjVVMmxuYm1GMGRYSmxJam9pWlhsS2VtRlhaSFZaV0ZJeFkyMVZhVTlwU2pCUldIQjJXVE5LVms1NmFGaFNSMlJzVVRKb2NtTklXa1ZVVlRsRldqQktXVTFGUmtaVFJFNUZVMGhLYkUxclRUTkxNSEJFVkROR2VGTnROVVJVVlRWVlltMDFiVnBGUm5sWldIQjZaRVJqTVZaSGFFeFBXRUpVVWtacmRrd3diek5aTUZaSlVteFdWRXd5T1VoV1JXeHNWa1ZPTUZSSE1WWlJNR04zVkd4R2JGa3pTblJUUm1zMFZVWk9hMVpWU2pCVU1WbDNZbXQwY0ZSclZuQmpia0poVFZjNWFtSldiSEZaYTNob1UyeHNWV0pGUmtWWGJVWnZWakZLVUZkcWJGSmhXRVp1V2xkb1EyRnVRak5TUjNNd1lWWkpOVTVXVmxkV1ZUVnlUMGhLYjFsVlRYbGhiVGcwVjBkYWVGbHFWbFppYlhoeFpFWkZkMDU1Y3pCaFZsSkpWRVpPTm1WRk1IcGxWWFJ2VFVaR1ZtRXdWVFJSVnpsSFVsaEtVRTFZUmxCU01WcFJVMVJDTmxsV2FIcFdWWEJ0WTBSU2JFMVVRazlPVjNSU1ZucFdUMU5XWTNaU1ZYUkZVMGhzYlU5VmJGaGtNMUl3WTFWc1lXTlhSakJTYTA1RVlVWmtjbUo2VmtSU00wSllUREkxUmsxWVl6SmxWM1JKVlZoQk1sVXhTbEppU0Zwd1VrVXdNRlpFVWt0VU1rWnNVVmQwYzFSV1VrMVVWV055V1RCYVRHSXpaRTlUVm05NVlraE9SR1JzVG5aUmFrWmFaVmRPVGxOVlNteGFiRXB1Wld0U2RVMHhSVGxRVTBselNXMWtjMkl5U21oaVJYUnNaVlZzYTBscWIybFpiVkpzV2xSVk1rNVVXWGRaTWxwcFRrUk9hazlYU1hsUFIwcHRUMVJvYkZsWFRtaGFiVVV5VGtSWmFXWlJQVDBpZlE9PSJ9 +`, mockServer.URL) + + onlineApp := &apptypes.App{ + ID: "app-id", + Slug: "app-slug", + Name: "app-name", + IsAirgap: false, + IsGitOps: false, + License: testLicense, + } + + airgapApp := &apptypes.App{ + ID: "app-id", + Slug: "app-slug", + Name: "app-name", + IsAirgap: true, + IsGitOps: false, + License: testLicense, + } + + type args struct { + app *apptypes.App + request handlers.StartUpgradeServiceRequest + } + tests := []struct { + name string + args args + mockStoreExpectations func() + wantParams *upgradeservicetypes.UpgradeServiceParams + }{ + { + name: "online", + args: args{ + app: onlineApp, + request: handlers.StartUpgradeServiceRequest{ + VersionLabel: "1.0.0", + UpdateCursor: "1", + ChannelID: "channel-id", + }, + }, + mockStoreExpectations: func() { + mockStore.EXPECT().GetRegistryDetailsForApp(onlineApp.ID).Return(registrytypes.RegistrySettings{}, nil) + mockStore.EXPECT().GetAppVersionBaseArchive(onlineApp.ID, "1.0.0").Return("base-archive", int64(1), nil) + mockStore.EXPECT().GetNextAppSequence(onlineApp.ID).Return(int64(2), nil) + }, + wantParams: &upgradeservicetypes.UpgradeServiceParams{ + Port: "", // port is random, we just check it's not empty + + AppID: onlineApp.ID, + AppSlug: onlineApp.Slug, + AppName: onlineApp.Name, + AppIsAirgap: onlineApp.IsAirgap, + AppIsGitOps: onlineApp.IsGitOps, + AppLicense: onlineApp.License, + AppArchive: "base-archive", + + Source: "Upstream Update", + BaseSequence: 1, + NextSequence: 2, + + UpdateVersionLabel: "1.0.0", + UpdateCursor: "1", + UpdateChannelID: "channel-id", + UpdateECVersion: "online-update-ec-version", + UpdateKOTSBin: "", // tmp file name is random, we just check it's not empty + UpdateAirgapBundle: "", + + CurrentECVersion: "current-ec-version", + + RegistryEndpoint: "", + RegistryUsername: "", + RegistryPassword: "", + RegistryNamespace: "", + RegistryIsReadOnly: false, + + ReportingInfo: reporting.GetReportingInfo(onlineApp.ID), + }, + }, + { + name: "airgap", + args: args{ + app: airgapApp, + request: handlers.StartUpgradeServiceRequest{ + VersionLabel: "1.0.0", + UpdateCursor: "1", + ChannelID: "channel-id", + }, + }, + mockStoreExpectations: func() { + mockStore.EXPECT().GetRegistryDetailsForApp(airgapApp.ID).Return(registrytypes.RegistrySettings{ + Hostname: "hostname", + Username: "username", + Password: "password", + Namespace: "namespace", + IsReadOnly: false, + }, nil) + mockStore.EXPECT().GetAppVersionBaseArchive(airgapApp.ID, "1.0.0").Return("base-archive", int64(1), nil) + mockStore.EXPECT().GetNextAppSequence(airgapApp.ID).Return(int64(2), nil) + }, + wantParams: &upgradeservicetypes.UpgradeServiceParams{ + Port: "", // port is random, we just check it's not empty + + AppID: airgapApp.ID, + AppSlug: airgapApp.Slug, + AppName: airgapApp.Name, + AppIsAirgap: airgapApp.IsAirgap, + AppIsGitOps: airgapApp.IsGitOps, + AppLicense: airgapApp.License, + AppArchive: "base-archive", + + Source: "Airgap Update", + BaseSequence: 1, + NextSequence: 2, + + UpdateVersionLabel: "1.0.0", + UpdateCursor: "1", + UpdateChannelID: "channel-id", + UpdateECVersion: "airgap-update-ec-version", + UpdateKOTSBin: "", // tmp file name is random, we just check it's not empty + UpdateAirgapBundle: updateAirgapBundle, + + CurrentECVersion: "current-ec-version", + + RegistryEndpoint: "hostname", + RegistryUsername: "username", + RegistryPassword: "password", + RegistryNamespace: "namespace", + RegistryIsReadOnly: false, + + ReportingInfo: reporting.GetReportingInfo(airgapApp.ID), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.mockStoreExpectations() + + gotParams, err := handlers.GetUpgradeServiceParams(mockStore, tt.args.app, tt.args.request) + require.NoError(t, err) + + assert.NotEqual(t, "", gotParams.Port) + assert.NotEqual(t, "", gotParams.UpdateKOTSBin) + + tt.wantParams.Port = gotParams.Port + tt.wantParams.UpdateKOTSBin = gotParams.UpdateKOTSBin + assert.Equal(t, tt.wantParams, gotParams) + + err = upgradeservice.Start(*gotParams) + require.NoError(t, err) + + // test proxying to the ping endpoint + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", fmt.Sprintf("http://kotsadm:3000/api/v1/upgrade-service/app/%s/ping", gotParams.AppSlug), nil) + r = mux.SetURLVars(r, map[string]string{"appSlug": gotParams.AppSlug}) + upgradeservice.Proxy(w, r) + assert.Equal(t, http.StatusOK, w.Code) + + // test GET proxying to an endpoint that is unknown to the current kots version + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", fmt.Sprintf("http://kotsadm:3000/api/v1/upgrade-service/app/%s/unknown", gotParams.AppSlug), nil) + r = mux.SetURLVars(r, map[string]string{"appSlug": gotParams.AppSlug}) + upgradeservice.Proxy(w, r) + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "unknown GET body", w.Body.String()) + + // test POST proxying to an endpoint that is unknown to the current kots version + w = httptest.NewRecorder() + r = httptest.NewRequest("POST", fmt.Sprintf("http://kotsadm:3000/api/v1/upgrade-service/app/%s/unknown", gotParams.AppSlug), nil) + r = mux.SetURLVars(r, map[string]string{"appSlug": gotParams.AppSlug}) + upgradeservice.Proxy(w, r) + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "unknown POST body", w.Body.String()) + + // test proxying to a non-existing endpoint + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", fmt.Sprintf("http://kotsadm:3000/api/v1/upgrade-service/app/%s/non-existing", gotParams.AppSlug), nil) + r = mux.SetURLVars(r, map[string]string{"appSlug": gotParams.AppSlug}) + upgradeservice.Proxy(w, r) + assert.Equal(t, http.StatusNotFound, w.Code) + + upgradeservice.Stop(gotParams.AppSlug) + }) + } +} + +func mockUpdateAirgapBundle(t *testing.T) string { + bundle := filepath.Join(t.TempDir(), "update-bundle.airgap") + defer os.Remove(bundle) + + f, err := os.Create(bundle) + require.NoError(t, err) + defer f.Close() + + gw := gzip.NewWriter(f) + tw := tar.NewWriter(gw) + + airgapYAML := `apiVersion: kots.io/v1beta1 +kind: Airgap +spec: + appSlug: app-slug + channelID: channel-id + updateCursor: "1" + embeddedClusterArtifacts: + additionalArtifacts: + kots: embedded-cluster/artifacts/kots.tar.gz + metadata: embedded-cluster/version-metadata.json` + + kotsTGZ := mockKOTSBinary(t) + + metadataJSON := `{ + "Versions": { + "Installer": "airgap-update-ec-version" + } +}` + + err = tw.WriteHeader(&tar.Header{ + Name: "airgap.yaml", + Mode: 0644, + Size: int64(len(airgapYAML)), + }) + require.NoError(t, err) + + _, err = tw.Write([]byte(airgapYAML)) + require.NoError(t, err) + + err = tw.WriteHeader(&tar.Header{ + Name: "embedded-cluster/artifacts/kots.tar.gz", + Mode: 0755, + Size: int64(len(kotsTGZ)), + }) + require.NoError(t, err) + + _, err = tw.Write(kotsTGZ) + require.NoError(t, err) + + err = tw.WriteHeader(&tar.Header{ + Name: "embedded-cluster/version-metadata.json", + Mode: 0644, + Size: int64(len(metadataJSON)), + }) + require.NoError(t, err) + + _, err = tw.Write([]byte(metadataJSON)) + require.NoError(t, err) + + tw.Close() + gw.Close() + + err = update.InitAvailableUpdatesDir() + require.NoError(t, err) + + err = update.RegisterAirgapUpdate("app-slug", bundle) + require.NoError(t, err) + + airgapUpdate, err := update.GetAirgapUpdate("app-slug", "channel-id", "1") + require.NoError(t, err) + + return airgapUpdate +} + +// use the test executable to mock the kots binary +// reference: https://abhinavg.net/2022/05/15/hijack-testmain +func mockKOTSBinary(t *testing.T) []byte { + testExe, err := os.Executable() + require.NoError(t, err) + + kotsBin, err := os.ReadFile(testExe) + require.NoError(t, err) + + buf := bytes.NewBuffer(nil) + gw := gzip.NewWriter(buf) + tw := tar.NewWriter(gw) + + err = tw.WriteHeader(&tar.Header{ + Name: "kots", + Mode: 0755, + Size: int64(len(kotsBin)), + }) + require.NoError(t, err) + + _, err = tw.Write(kotsBin) + require.NoError(t, err) + + tw.Close() + gw.Close() + + return buf.Bytes() +} + +func mockUpgradeServiceCmd() { + wantArgs := []string{"upgrade-service", "start", "-"} + if gotArgs := os.Args[1:]; !slices.Equal(wantArgs, gotArgs) { + log.Fatalf(`expected arguments %q, got %q`, wantArgs, gotArgs) + } + + data, err := io.ReadAll(os.Stdin) + if err != nil { + log.Fatalf("Failed to read stdin: %v", err) + } + + var params struct { + Port string `yaml:"port"` + AppSlug string `yaml:"appSlug"` + } + if err := yaml.Unmarshal(data, ¶ms); err != nil { + log.Fatalf("Failed to unmarshal params YAML: %v", err) + } + + handler := func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == fmt.Sprintf("/api/v1/upgrade-service/app/%s/ping", params.AppSlug) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + return + } + if r.URL.Path == fmt.Sprintf("/api/v1/upgrade-service/app/%s/unknown", params.AppSlug) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(fmt.Sprintf("unknown %s body", r.Method))) + return + } + w.WriteHeader(http.StatusNotFound) + } + + http.HandleFunc("/", handler) + fmt.Println("starting mock upgrade service on port", params.Port) + + if err := http.ListenAndServe(":"+params.Port, nil); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server failed: %v", err) + } +} diff --git a/pkg/handlers/upload.go b/pkg/handlers/upload.go index 2902e41a85..b40583681f 100644 --- a/pkg/handlers/upload.go +++ b/pkg/handlers/upload.go @@ -16,6 +16,7 @@ import ( "github.com/replicatedhq/kots/pkg/preflight" "github.com/replicatedhq/kots/pkg/render" rendertypes "github.com/replicatedhq/kots/pkg/render/types" + "github.com/replicatedhq/kots/pkg/reporting" "github.com/replicatedhq/kots/pkg/store" storetypes "github.com/replicatedhq/kots/pkg/store/types" "github.com/replicatedhq/kots/pkg/util" @@ -165,6 +166,7 @@ func (h *Handler) UploadExistingApp(w http.ResponseWriter, r *http.Request) { Downstreams: downstreams, RegistrySettings: registrySettings, Sequence: nextAppSequence, + ReportingInfo: reporting.GetReportingInfo(a.ID), }) if err != nil { cause := errors.Cause(err) @@ -188,7 +190,7 @@ func (h *Handler) UploadExistingApp(w http.ResponseWriter, r *http.Request) { return } - newSequence, err := store.GetStore().CreateAppVersion(a.ID, &baseSequence, archiveDir, "KOTS Upload", uploadExistingAppRequest.SkipPreflights, &version.DownstreamGitOps{}, render.Renderer{}) + newSequence, err := store.GetStore().CreateAppVersion(a.ID, &baseSequence, archiveDir, "KOTS Upload", uploadExistingAppRequest.SkipPreflights, render.Renderer{}) if err != nil { uploadResponse.Error = util.StrPointer("failed to create app version") logger.Error(errors.Wrap(err, *uploadResponse.Error)) diff --git a/pkg/image/airgap.go b/pkg/image/airgap.go index f546e612ce..eabf10b905 100644 --- a/pkg/image/airgap.go +++ b/pkg/image/airgap.go @@ -191,15 +191,6 @@ func TagAndPushImagesFromBundle(airgapBundle string, options imagetypes.PushImag return nil } -func PathToRegistryECImage(srcImage, registryNamespace string) (string, error) { - imageParts := strings.Split(srcImage, "/") - imageName := imageParts[len(imageParts)-1] - if imageName == "" { - return "", errors.New("empty image name") - } - return filepath.Join(registryNamespace, "embedded-cluster", imageName), nil -} - func PushECImagesFromTempRegistry(airgapRootDir string, airgap *kotsv1beta1.Airgap, options imagetypes.PushImagesOptions) error { artifacts := airgap.Spec.EmbeddedClusterArtifacts if artifacts == nil || artifacts.Registry.Dir == "" || len(artifacts.Registry.SavedImages) == 0 { @@ -207,6 +198,11 @@ func PushECImagesFromTempRegistry(airgapRootDir string, airgap *kotsv1beta1.Airg } imagesDir := filepath.Join(airgapRootDir, artifacts.Registry.Dir) + if _, err := os.Stat(imagesDir); os.IsNotExist(err) { + // images were already pushed from the CLI + return nil + } + tempRegistry := &dockerregistry.TempRegistry{} if err := tempRegistry.Start(imagesDir); err != nil { return errors.Wrap(err, "failed to start temp registry") @@ -254,12 +250,12 @@ func PushECImagesFromTempRegistry(airgapRootDir string, airgap *kotsv1beta1.Airg } srcImage := parsed.String() - imagePath, err := PathToRegistryECImage(srcImage, options.Registry.Namespace) + destImage, err := imageutil.DestECImage(options.Registry, srcImage) if err != nil { return errors.Wrap(err, "failed to get registry image path") } - destStr := fmt.Sprintf("docker://%s/%s", options.Registry.Endpoint, imagePath) + destStr := fmt.Sprintf("docker://%s", destImage) destRef, err := alltransports.ParseImageName(destStr) if err != nil { return errors.Wrapf(err, "failed to parse dest image %s", destStr) @@ -503,7 +499,7 @@ func PushImagesFromDockerArchivePath(airgapRootDir string, options imagetypes.Pu } func PushImagesFromDockerArchiveBundle(airgapBundle string, options imagetypes.PushImagesOptions) error { - if exists, err := archives.DirExistsInAirgap("images", airgapBundle); err != nil { + if exists, err := archives.DirExistsInTGZArchive("images", airgapBundle); err != nil { return errors.Wrap(err, "failed to check if images dir exists in airgap bundle") } else if !exists { // images were already pushed from the CLI diff --git a/pkg/image/airgap_test.go b/pkg/image/airgap_test.go index 4590eeefbc..939f845be6 100644 --- a/pkg/image/airgap_test.go +++ b/pkg/image/airgap_test.go @@ -20,48 +20,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestPathToRegistryECImage(t *testing.T) { - for _, tt := range []struct { - name string - image string - namespace string - expected string - err string - }{ - { - name: "ecr", - image: "123456789012.dkr.ecr.us-west-2.amazonaws.com/myimage:latest", - namespace: "myapp", - expected: "myapp/embedded-cluster/myimage:latest", - }, - { - name: "no registry", - image: "myimage:latest", - namespace: "myapp", - expected: "myapp/embedded-cluster/myimage:latest", - }, - { - name: "no namespace", - image: "myimage:latest", - expected: "embedded-cluster/myimage:latest", - }, - { - name: "empty image name", - err: "empty image name", - }, - } { - t.Run(tt.name, func(t *testing.T) { - result, err := PathToRegistryECImage(tt.image, tt.namespace) - if tt.err == "" { - require.Equal(t, tt.expected, result) - require.NoError(t, err) - return - } - require.Contains(t, err.Error(), tt.err) - }) - } -} - func TestPushEmbeddedClusterArtifacts(t *testing.T) { testAppSlug := "test-app" testChannelID := "test-tag" diff --git a/pkg/imageutil/image.go b/pkg/imageutil/image.go index 7f10a1ad4d..0b71524b22 100644 --- a/pkg/imageutil/image.go +++ b/pkg/imageutil/image.go @@ -98,10 +98,22 @@ func DestImage(destRegistry registrytypes.RegistryOptions, srcImage string) (str imageParts := strings.Split(srcImage, "/") lastPart := imageParts[len(imageParts)-1] - if destRegistry.Namespace == "" { - return fmt.Sprintf("%s/%s", destRegistry.Endpoint, lastPart), nil + return filepath.Join(destRegistry.Endpoint, destRegistry.Namespace, lastPart), nil +} + +// DestECImage returns the location to push an embedded cluster image to on the dest registry +func DestECImage(destRegistry registrytypes.RegistryOptions, srcImage string) (string, error) { + // parsing as a docker reference strips the tag if both a tag and a digest are used + parsed, err := reference.ParseDockerRef(srcImage) + if err != nil { + return "", errors.Wrap(err, "failed to normalize source image") } - return fmt.Sprintf("%s/%s/%s", destRegistry.Endpoint, destRegistry.Namespace, lastPart), nil + srcImage = parsed.String() + + imageParts := strings.Split(srcImage, "/") + lastPart := imageParts[len(imageParts)-1] + + return filepath.Join(destRegistry.Endpoint, destRegistry.Namespace, "embedded-cluster", lastPart), nil } // DestImageFromKustomizeImage returns the location to push the image to from a kustomize image type diff --git a/pkg/imageutil/image_test.go b/pkg/imageutil/image_test.go index 3e4a584371..c6b617d076 100644 --- a/pkg/imageutil/image_test.go +++ b/pkg/imageutil/image_test.go @@ -476,7 +476,79 @@ func Test_DestImage(t *testing.T) { req.NoError(err) if got != tt.want { - t.Errorf("DestImageName() = %v, want %v", got, tt.want) + t.Errorf("DestImage() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_DestECImage(t *testing.T) { + registryOps := registrytypes.RegistryOptions{ + Endpoint: "localhost:5000", + Namespace: "somebigbank", + } + + type args struct { + registry registrytypes.RegistryOptions + srcImage string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "ECR style image", + args: args{ + registry: registryOps, + srcImage: "411111111111.dkr.ecr.us-west-1.amazonaws.com/myrepo:v0.0.1", + }, + want: fmt.Sprintf("%s/%s/embedded-cluster/myrepo:v0.0.1", registryOps.Endpoint, registryOps.Namespace), + }, + { + name: "Quay image with tag", + args: args{ + registry: registryOps, + srcImage: "quay.io/someorg/debian:0.1", + }, + want: fmt.Sprintf("%s/%s/embedded-cluster/debian:0.1", registryOps.Endpoint, registryOps.Namespace), + }, + { + name: "Quay image with digest", + args: args{ + registry: registryOps, + srcImage: "quay.io/someorg/debian@sha256:17c5f462c92fc39303e6363c65e074559f8d6a1354150027ed5053557e3298c5", + }, + want: fmt.Sprintf("%s/%s/embedded-cluster/debian@sha256:17c5f462c92fc39303e6363c65e074559f8d6a1354150027ed5053557e3298c5", registryOps.Endpoint, registryOps.Namespace), + }, + { + name: "Image with tag and digest", + args: args{ + registry: registryOps, + srcImage: "quay.io/someorg/debian:0.1@sha256:17c5f462c92fc39303e6363c65e074559f8d6a1354150027ed5053557e3298c5", + }, + want: fmt.Sprintf("%s/%s/embedded-cluster/debian@sha256:17c5f462c92fc39303e6363c65e074559f8d6a1354150027ed5053557e3298c5", registryOps.Endpoint, registryOps.Namespace), + }, + { + name: "No Namespace", + args: args{ + registry: registrytypes.RegistryOptions{ + Endpoint: "localhost:5000", + }, + srcImage: "quay.io/someorg/debian:0.1", + }, + want: fmt.Sprintf("%s/embedded-cluster/debian:0.1", registryOps.Endpoint), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := require.New(t) + + got, err := DestECImage(tt.args.registry, tt.args.srcImage) + req.NoError(err) + + if got != tt.want { + t.Errorf("DestECImage() = %v, want %v", got, tt.want) } }) } diff --git a/pkg/k8sutil/deployment.go b/pkg/k8sutil/deployment.go index e25a166a3e..251f652c4e 100644 --- a/pkg/k8sutil/deployment.go +++ b/pkg/k8sutil/deployment.go @@ -30,7 +30,7 @@ func WaitForDeploymentReady(ctx context.Context, clientset kubernetes.Interface, time.Sleep(time.Second) - if time.Now().Sub(start) > timeout { + if time.Since(start) > timeout { return &types.ErrorTimeout{Message: "timeout waiting for deployment to become ready"} } } diff --git a/pkg/kotsadm/main.go b/pkg/kotsadm/main.go index cf87267135..7ddeb1a42c 100644 --- a/pkg/kotsadm/main.go +++ b/pkg/kotsadm/main.go @@ -216,7 +216,7 @@ func Deploy(deployOptions types.DeployOptions, log *logger.CLILogger) error { } if deployOptions.AppImagesPushed { - airgapMetadata, err := archives.GetFileFromAirgap("airgap.yaml", deployOptions.AirgapBundle) + airgapMetadata, err := archives.GetFileContentFromTGZArchive("airgap.yaml", deployOptions.AirgapBundle) if err != nil { return errors.Wrap(err, "failed to get airgap.yaml from bundle") } diff --git a/pkg/kotsadmconfig/config.go b/pkg/kotsadmconfig/config.go index e887f5cb19..3b335ffa38 100644 --- a/pkg/kotsadmconfig/config.go +++ b/pkg/kotsadmconfig/config.go @@ -2,10 +2,14 @@ package kotsadmconfig import ( "context" + "encoding/base64" + "fmt" "os" + "strconv" "github.com/pkg/errors" kotsconfig "github.com/replicatedhq/kots/pkg/config" + "github.com/replicatedhq/kots/pkg/crypto" "github.com/replicatedhq/kots/pkg/k8sutil" "github.com/replicatedhq/kots/pkg/kotsutil" "github.com/replicatedhq/kots/pkg/logger" @@ -13,6 +17,7 @@ import ( "github.com/replicatedhq/kots/pkg/template" "github.com/replicatedhq/kots/pkg/util" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/multitype" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -91,6 +96,76 @@ func NeedsConfiguration(appSlug string, sequence int64, isAirgap bool, kotsKinds return false, nil } +func GetMissingRequiredConfig(configGroups []kotsv1beta1.ConfigGroup) ([]string, []string) { + requiredItems := make([]string, 0, 0) + requiredItemsTitles := make([]string, 0, 0) + for _, group := range configGroups { + if group.When == "false" { + continue + } + for _, item := range group.Items { + if IsRequiredItem(item) && IsUnsetItem(item) { + requiredItems = append(requiredItems, item.Name) + if item.Title != "" { + requiredItemsTitles = append(requiredItemsTitles, item.Title) + } else { + requiredItemsTitles = append(requiredItemsTitles, item.Name) + } + } + } + } + + return requiredItems, requiredItemsTitles +} + +func UpdateAppConfigValues(values map[string]kotsv1beta1.ConfigValue, configGroups []kotsv1beta1.ConfigGroup) map[string]kotsv1beta1.ConfigValue { + for _, group := range configGroups { + for _, item := range group.Items { + if item.Type == "file" { + v := values[item.Name] + v.Filename = item.Filename + values[item.Name] = v + } + if item.Value.Type == multitype.Bool { + updatedValue := item.Value.BoolVal + v := values[item.Name] + v.Value = strconv.FormatBool(updatedValue) + values[item.Name] = v + } else if item.Value.Type == multitype.String { + updatedValue := item.Value.String() + if item.Type == "password" { + // encrypt using the key + // if the decryption succeeds, don't encrypt again + _, err := util.DecryptConfigValue(updatedValue) + if err != nil { + updatedValue = base64.StdEncoding.EncodeToString(crypto.Encrypt([]byte(updatedValue))) + } + } + + v := values[item.Name] + v.Value = updatedValue + values[item.Name] = v + } + for _, repeatableValues := range item.ValuesByGroup { + // clear out all variadic values for this group first + for name, value := range values { + if value.RepeatableItem == item.Name { + delete(values, name) + } + } + // add variadic groups back in declaratively + for itemName, valueItem := range repeatableValues { + v := values[itemName] + v.Value = fmt.Sprintf("%v", valueItem) + v.RepeatableItem = item.Name + values[itemName] = v + } + } + } + } + return values +} + func ReadConfigValuesFromInClusterSecret() (string, error) { log := logger.NewCLILogger(os.Stdout) diff --git a/pkg/kotsadmconfig/config_test.go b/pkg/kotsadmconfig/config_test.go index 4b55e27ba7..287ac1a509 100644 --- a/pkg/kotsadmconfig/config_test.go +++ b/pkg/kotsadmconfig/config_test.go @@ -1 +1,82 @@ package kotsadmconfig + +import ( + "testing" + + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/multitype" + "github.com/stretchr/testify/require" +) + +func Test_updateAppConfigValues(t *testing.T) { + tests := []struct { + name string + values map[string]kotsv1beta1.ConfigValue + configGroups []kotsv1beta1.ConfigGroup + want map[string]kotsv1beta1.ConfigValue + }{ + { + name: "update config values", + values: map[string]kotsv1beta1.ConfigValue{ + "secretName-1": { + Value: "111", + RepeatableItem: "secretName", + }, + "secretName-2": { + Value: "456", + RepeatableItem: "secretName", + }, + "podName": { + Value: "test-pod", + }, + }, + configGroups: []kotsv1beta1.ConfigGroup{ + { + Name: "secret", + Items: []kotsv1beta1.ConfigItem{ + { + Name: "secretName", + ValuesByGroup: kotsv1beta1.ValuesByGroup{ + "Secrets": { + "secretName-1": "123", + "secretName-2": "456", + }, + }, + }, + }, + }, + { + Name: "pod", + Items: []kotsv1beta1.ConfigItem{ + { + Name: "podName", + Value: multitype.BoolOrString{Type: 0, StrVal: "real-pod"}, + }, + }, + }, + }, + want: map[string]kotsv1beta1.ConfigValue{ + "podName": { + Value: "real-pod", + }, + "secretName": {}, + "secretName-1": { + Value: "123", + RepeatableItem: "secretName", + }, + "secretName-2": { + Value: "456", + RepeatableItem: "secretName", + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := require.New(t) + updatedValues := UpdateAppConfigValues(test.values, test.configGroups) + + req.Equal(test.want, updatedValues) + }) + } +} diff --git a/pkg/kotsadmlicense/license.go b/pkg/kotsadmlicense/license.go index ad4f007b51..78a2ec83b8 100644 --- a/pkg/kotsadmlicense/license.go +++ b/pkg/kotsadmlicense/license.go @@ -12,8 +12,8 @@ import ( "github.com/replicatedhq/kots/pkg/preflight" "github.com/replicatedhq/kots/pkg/render" "github.com/replicatedhq/kots/pkg/replicatedapp" + "github.com/replicatedhq/kots/pkg/reporting" "github.com/replicatedhq/kots/pkg/store" - "github.com/replicatedhq/kots/pkg/version" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "k8s.io/client-go/kubernetes/scheme" ) @@ -84,7 +84,8 @@ func Sync(a *apptypes.App, licenseString string, failOnVersionCreate bool) (*kot if updatedLicense.Spec.ChannelID != currentLicense.Spec.ChannelID { channelChanged = true } - newSequence, err := store.GetStore().UpdateAppLicense(a.ID, latestSequence, archiveDir, updatedLicense, licenseString, channelChanged, failOnVersionCreate, &version.DownstreamGitOps{}, &render.Renderer{}) + reportingInfo := reporting.GetReportingInfo(a.ID) + newSequence, err := store.GetStore().UpdateAppLicense(a.ID, latestSequence, archiveDir, updatedLicense, licenseString, channelChanged, failOnVersionCreate, &render.Renderer{}, reportingInfo) if err != nil { return nil, false, errors.Wrap(err, "failed to update license") } @@ -161,7 +162,7 @@ func Change(a *apptypes.App, newLicenseString string) (*kotsv1beta1.License, err return nil, errors.Wrap(err, "failed to check if license exists") } if existingLicense != nil { - resolved, err := kotslicense.ResolveExistingLicense(newLicense) + resolved, err := ResolveExistingLicense(newLicense) if err != nil { logger.Error(errors.Wrap(err, "failed to resolve existing license conflict")) } @@ -190,7 +191,8 @@ func Change(a *apptypes.App, newLicenseString string) (*kotsv1beta1.License, err if newLicense.Spec.ChannelID != currentLicense.Spec.ChannelID { channelChanged = true } - newSequence, err := store.GetStore().UpdateAppLicense(a.ID, latestSequence, archiveDir, newLicense, newLicenseString, channelChanged, true, &version.DownstreamGitOps{}, &render.Renderer{}) + reportingInfo := reporting.GetReportingInfo(a.ID) + newSequence, err := store.GetStore().UpdateAppLicense(a.ID, latestSequence, archiveDir, newLicense, newLicenseString, channelChanged, true, &render.Renderer{}, reportingInfo) if err != nil { return nil, errors.Wrap(err, "failed to update license") } @@ -223,3 +225,40 @@ func CheckIfLicenseExists(license []byte) (*kotsv1beta1.License, error) { return nil, nil } + +func ResolveExistingLicense(newLicense *kotsv1beta1.License) (bool, error) { + notInstalledApps, err := store.GetStore().ListFailedApps() + if err != nil { + logger.Error(errors.Wrap(err, "failed to list failed apps")) + return false, err + } + + for _, app := range notInstalledApps { + decode := scheme.Codecs.UniversalDeserializer().Decode + obj, _, err := decode([]byte(app.License), nil, nil) + if err != nil { + continue + } + license := obj.(*kotsv1beta1.License) + if license.Spec.LicenseID != newLicense.Spec.LicenseID { + continue + } + + if err := store.GetStore().RemoveApp(app.ID); err != nil { + return false, errors.Wrap(err, "failed to remove existing app record") + } + } + + // check if license still exists + allLicenses, err := store.GetStore().GetAllAppLicenses() + if err != nil { + return false, errors.Wrap(err, "failed to get all app licenses") + } + for _, l := range allLicenses { + if l.Spec.LicenseID == newLicense.Spec.LicenseID { + return false, nil + } + } + + return true, nil +} diff --git a/pkg/kotsadmupstream/upstream.go b/pkg/kotsadmupstream/upstream.go index 5b37a28794..525dd8f7a5 100644 --- a/pkg/kotsadmupstream/upstream.go +++ b/pkg/kotsadmupstream/upstream.go @@ -17,10 +17,10 @@ import ( "github.com/replicatedhq/kots/pkg/render" "github.com/replicatedhq/kots/pkg/reporting" "github.com/replicatedhq/kots/pkg/store" + "github.com/replicatedhq/kots/pkg/tasks" "github.com/replicatedhq/kots/pkg/upstream" "github.com/replicatedhq/kots/pkg/upstream/types" "github.com/replicatedhq/kots/pkg/util" - "github.com/replicatedhq/kots/pkg/version" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" ) @@ -36,8 +36,8 @@ func DownloadUpdate(appID string, update types.Update, skipPreflights bool, skip go func() { for { select { - case <-time.After(time.Second): - if err := store.GetStore().UpdateTaskStatusTimestamp(taskID); err != nil { + case <-time.After(time.Second * 2): + if err := tasks.UpdateTaskStatusTimestamp(taskID); err != nil { logger.Error(errors.Wrapf(err, "failed to update %s task status timestamp", taskID)) } case <-finishedCh: @@ -47,7 +47,7 @@ func DownloadUpdate(appID string, update types.Update, skipPreflights bool, skip }() } - if err := store.GetStore().SetTaskStatus(taskID, "Fetching update...", "running"); err != nil { + if err := tasks.SetTaskStatus(taskID, "Fetching update...", "running"); err != nil { finalError = errors.Wrap(err, "failed to set task status") return } @@ -66,7 +66,7 @@ func DownloadUpdate(appID string, update types.Update, skipPreflights bool, skip logger.Error(errors.Wrapf(err, "failed to update next app version diff summary for base sequence %d", *update.AppSequence)) } } - err := store.GetStore().ClearTaskStatus(taskID) + err := tasks.ClearTaskStatus(taskID) if err != nil { logger.Error(errors.Wrapf(err, "failed to clear %s task status", taskID)) } @@ -92,7 +92,7 @@ func DownloadUpdate(appID string, update types.Update, skipPreflights bool, skip if update.AppSequence != nil || finalSequence != nil { // a version already exists or has been created - err := store.GetStore().SetTaskStatus(taskID, errMsg, "failed") + err := tasks.SetTaskStatus(taskID, errMsg, "failed") if err != nil { logger.Error(errors.Wrapf(err, "failed to set %s task status", taskID)) } @@ -103,7 +103,7 @@ func DownloadUpdate(appID string, update types.Update, skipPreflights bool, skip newSequence, err := store.GetStore().CreatePendingDownloadAppVersion(appID, update, kotsApplication, license) if err != nil { logger.Error(errors.Wrapf(err, "failed to create pending download app version for update %s", update.VersionLabel)) - if err := store.GetStore().SetTaskStatus(taskID, errMsg, "failed"); err != nil { + if err := tasks.SetTaskStatus(taskID, errMsg, "failed"); err != nil { logger.Error(errors.Wrapf(err, "failed to set %s task status", taskID)) } return @@ -113,10 +113,10 @@ func DownloadUpdate(appID string, update types.Update, skipPreflights bool, skip // a pending download version has been created, bind the download error to it // clear the global task status at the end to avoid a race condition with the UI sequenceTaskID := fmt.Sprintf("update-download.%d", *finalSequence) - if err := store.GetStore().SetTaskStatus(sequenceTaskID, errMsg, "failed"); err != nil { + if err := tasks.SetTaskStatus(sequenceTaskID, errMsg, "failed"); err != nil { logger.Error(errors.Wrapf(err, "failed to set %s task status", sequenceTaskID)) } - if err := store.GetStore().ClearTaskStatus(taskID); err != nil { + if err := tasks.ClearTaskStatus(taskID); err != nil { logger.Error(errors.Wrapf(err, "failed to clear %s task status", taskID)) } }() @@ -143,7 +143,7 @@ func DownloadUpdate(appID string, update types.Update, skipPreflights bool, skip go func() { scanner := bufio.NewScanner(pipeReader) for scanner.Scan() { - if err := store.GetStore().SetTaskStatus(taskID, scanner.Text(), "running"); err != nil { + if err := tasks.SetTaskStatus(taskID, scanner.Text(), "running"); err != nil { logger.Error(err) } } @@ -250,14 +250,14 @@ func DownloadUpdate(appID string, update types.Update, skipPreflights bool, skip if afterKotsKinds.Installation.Spec.UpdateCursor == beforeInstallation.UpdateCursor && afterKotsKinds.Installation.Spec.ChannelID == beforeInstallation.ChannelID { return } - newSequence, err := store.GetStore().CreateAppVersion(a.ID, &baseSequence, archiveDir, "Upstream Update", skipPreflights, &version.DownstreamGitOps{}, render.Renderer{}) + newSequence, err := store.GetStore().CreateAppVersion(a.ID, &baseSequence, archiveDir, "Upstream Update", skipPreflights, render.Renderer{}) if err != nil { finalError = errors.Wrap(err, "failed to create version") return } finalSequence = &newSequence } else { - err := store.GetStore().UpdateAppVersion(a.ID, *update.AppSequence, &baseSequence, archiveDir, "Upstream Update", skipPreflights, &version.DownstreamGitOps{}, render.Renderer{}) + err := store.GetStore().UpdateAppVersion(a.ID, *update.AppSequence, &baseSequence, archiveDir, "Upstream Update", skipPreflights, render.Renderer{}) if err != nil { finalError = errors.Wrap(err, "failed to create version") return diff --git a/pkg/kotsutil/kots.go b/pkg/kotsutil/kots.go index 50ebe68cef..242bc0e54f 100644 --- a/pkg/kotsutil/kots.go +++ b/pkg/kotsutil/kots.go @@ -6,6 +6,7 @@ import ( "compress/gzip" "context" "encoding/base64" + "encoding/json" "fmt" "os" "path" @@ -40,7 +41,6 @@ import ( kuberneteserrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/serializer/json" serializer "k8s.io/apimachinery/pkg/runtime/serializer/json" "k8s.io/client-go/kubernetes/scheme" applicationv1beta1 "sigs.k8s.io/application/api/v1beta1" @@ -1368,7 +1368,7 @@ func FindAirgapMetaInDir(root string) (*kotsv1beta1.Airgap, error) { } func FindAirgapMetaInBundle(airgapBundle string) (*kotsv1beta1.Airgap, error) { - content, err := archives.GetFileFromAirgap("airgap.yaml", airgapBundle) + content, err := archives.GetFileContentFromTGZArchive("airgap.yaml", airgapBundle) if err != nil { return nil, errors.Wrap(err, "failed to extract airgap.yaml file") } @@ -1510,7 +1510,7 @@ func MustMarshalInstallation(installation *kotsv1beta1.Installation) []byte { } func MarshalRuntimeObject(obj runtime.Object) ([]byte, error) { - s := json.NewYAMLSerializer(json.DefaultMetaFactory, scheme.Scheme, scheme.Scheme) + s := serializer.NewYAMLSerializer(serializer.DefaultMetaFactory, scheme.Scheme, scheme.Scheme) var b bytes.Buffer if err := s.Encode(obj, &b); err != nil { @@ -1529,3 +1529,72 @@ func SaveInstallation(installation *kotsv1beta1.Installation, upstreamDir string } return nil } + +func GetKOTSBinFromAirgapBundle(airgapBundle string) (string, error) { + airgap, err := FindAirgapMetaInBundle(airgapBundle) + if err != nil { + return "", errors.Wrap(err, "failed to find airgap meta in bundle") + } + if airgap.Spec.EmbeddedClusterArtifacts == nil { + return "", errors.New("airgap bundle does not contain embedded cluster artifacts") + } + if airgap.Spec.EmbeddedClusterArtifacts.AdditionalArtifacts == nil { + return "", errors.New("airgap bundle does not contain additional embedded cluster artifacts") + } + + location, ok := airgap.Spec.EmbeddedClusterArtifacts.AdditionalArtifacts["kots"] + if !ok { + return "", errors.New("airgap bundle does not contain kots binary") + } + + kotsTGZ, err := archives.GetFileFromTGZArchive(location, airgapBundle) + if err != nil { + return "", errors.Wrap(err, "failed to get kots tarball from airgap bundle") + } + defer os.Remove(kotsTGZ) + + kotsBin, err := archives.GetFileFromTGZArchive("kots", kotsTGZ) + if err != nil { + return "", errors.Wrap(err, "failed to get kots binary from kots tarball") + } + if err := os.Chmod(kotsBin, 0755); err != nil { + return "", errors.Wrap(err, "failed to chmod kots binary") + } + return kotsBin, nil +} + +func GetECVersionFromAirgapBundle(airgapBundle string) (string, error) { + airgap, err := FindAirgapMetaInBundle(airgapBundle) + if err != nil { + return "", errors.Wrap(err, "failed to find airgap meta in bundle") + } + if airgap.Spec.EmbeddedClusterArtifacts == nil { + return "", errors.New("airgap bundle does not contain embedded cluster artifacts") + } + if airgap.Spec.EmbeddedClusterArtifacts.Metadata == "" { + return "", errors.New("airgap bundle does not contain metadata") + } + + metadataContent, err := archives.GetFileContentFromTGZArchive(airgap.Spec.EmbeddedClusterArtifacts.Metadata, airgapBundle) + if err != nil { + return "", errors.Wrap(err, "failed to get embedded cluster metadata from airgap bundle") + } + + // use minimal/generic struct to avoid schema mismatches + type ecMetadata struct { + Versions map[string]string + } + var meta ecMetadata + if err := json.Unmarshal(metadataContent, &meta); err != nil { + return "", errors.Wrap(err, "failed to unmarshal embedded cluster metadata") + } + if meta.Versions == nil { + return "", errors.New("versions not found in embedded cluster metadata") + } + + ecVersion, ok := meta.Versions["Installer"] + if !ok { + return "", errors.New("installer version not found in embedded cluster metadata") + } + return ecVersion, nil +} diff --git a/pkg/license/license.go b/pkg/license/license.go index 8cb8afa740..3eb4e7d669 100644 --- a/pkg/license/license.go +++ b/pkg/license/license.go @@ -4,49 +4,9 @@ import ( "time" "github.com/pkg/errors" - "github.com/replicatedhq/kots/pkg/logger" - "github.com/replicatedhq/kots/pkg/store" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" - "github.com/replicatedhq/kotskinds/client/kotsclientset/scheme" ) -func ResolveExistingLicense(newLicense *kotsv1beta1.License) (bool, error) { - notInstalledApps, err := store.GetStore().ListFailedApps() - if err != nil { - logger.Error(errors.Wrap(err, "failed to list failed apps")) - return false, err - } - - for _, app := range notInstalledApps { - decode := scheme.Codecs.UniversalDeserializer().Decode - obj, _, err := decode([]byte(app.License), nil, nil) - if err != nil { - continue - } - license := obj.(*kotsv1beta1.License) - if license.Spec.LicenseID != newLicense.Spec.LicenseID { - continue - } - - if err := store.GetStore().RemoveApp(app.ID); err != nil { - return false, errors.Wrap(err, "failed to remove existing app record") - } - } - - // check if license still exists - allLicenses, err := store.GetStore().GetAllAppLicenses() - if err != nil { - return false, errors.Wrap(err, "failed to get all app licenses") - } - for _, l := range allLicenses { - if l.Spec.LicenseID == newLicense.Spec.LicenseID { - return false, nil - } - } - - return true, nil -} - func LicenseIsExpired(license *kotsv1beta1.License) (bool, error) { val, found := license.Spec.Entitlements["expires_at"] if !found { diff --git a/pkg/online/online.go b/pkg/online/online.go index bc79fbda54..8ba1ae7ba4 100644 --- a/pkg/online/online.go +++ b/pkg/online/online.go @@ -22,6 +22,7 @@ import ( storetypes "github.com/replicatedhq/kots/pkg/store/types" "github.com/replicatedhq/kots/pkg/supportbundle" supportbundletypes "github.com/replicatedhq/kots/pkg/supportbundle/types" + "github.com/replicatedhq/kots/pkg/tasks" "github.com/replicatedhq/kots/pkg/updatechecker" "github.com/replicatedhq/kots/pkg/util" "github.com/replicatedhq/kots/pkg/version" @@ -40,7 +41,7 @@ func CreateAppFromOnline(opts CreateOnlineAppOpts) (_ *kotsutil.KotsKinds, final logger.Debug("creating app from online", zap.String("upstreamURI", opts.UpstreamURI)) - if err := store.GetStore().SetTaskStatus("online-install", "Uploading license...", "running"); err != nil { + if err := tasks.SetTaskStatus("online-install", "Uploading license...", "running"); err != nil { return nil, errors.Wrap(err, "failed to set task status") } @@ -49,8 +50,8 @@ func CreateAppFromOnline(opts CreateOnlineAppOpts) (_ *kotsutil.KotsKinds, final go func() { for { select { - case <-time.After(time.Second): - if err := store.GetStore().UpdateTaskStatusTimestamp("online-install"); err != nil { + case <-time.After(time.Second * 2): + if err := tasks.UpdateTaskStatusTimestamp("online-install"); err != nil { logger.Error(err) } case <-finishedCh: @@ -62,7 +63,7 @@ func CreateAppFromOnline(opts CreateOnlineAppOpts) (_ *kotsutil.KotsKinds, final var app *apptypes.App defer func() { if finalError == nil { - if err := store.GetStore().ClearTaskStatus("online-install"); err != nil { + if err := tasks.ClearTaskStatus("online-install"); err != nil { logger.Error(errors.Wrap(err, "failed to clear install task status")) } if err := store.GetStore().SetAppInstallState(opts.PendingApp.ID, "installed"); err != nil { @@ -72,7 +73,7 @@ func CreateAppFromOnline(opts CreateOnlineAppOpts) (_ *kotsutil.KotsKinds, final logger.Error(errors.Wrap(err, "failed to configure update checker")) } } else { - if err := store.GetStore().SetTaskStatus("online-install", finalError.Error(), "failed"); err != nil { + if err := tasks.SetTaskStatus("online-install", finalError.Error(), "failed"); err != nil { logger.Error(errors.Wrap(err, "failed to set error on install task status")) } if err := store.GetStore().SetAppInstallState(opts.PendingApp.ID, "install_error"); err != nil { @@ -85,7 +86,7 @@ func CreateAppFromOnline(opts CreateOnlineAppOpts) (_ *kotsutil.KotsKinds, final go func() { scanner := bufio.NewScanner(pipeReader) for scanner.Scan() { - if err := store.GetStore().SetTaskStatus("online-install", scanner.Text(), "running"); err != nil { + if err := tasks.SetTaskStatus("online-install", scanner.Text(), "running"); err != nil { logger.Error(err) } } @@ -175,7 +176,7 @@ func CreateAppFromOnline(opts CreateOnlineAppOpts) (_ *kotsutil.KotsKinds, final return nil, errors.Wrap(err, "failed to set app is not airgap") } - newSequence, err := store.GetStore().CreateAppVersion(opts.PendingApp.ID, nil, tmpRoot, "Online Install", opts.SkipPreflights, &version.DownstreamGitOps{}, render.Renderer{}) + newSequence, err := store.GetStore().CreateAppVersion(opts.PendingApp.ID, nil, tmpRoot, "Online Install", opts.SkipPreflights, render.Renderer{}) if err != nil { return nil, errors.Wrap(err, "failed to create new version") } diff --git a/pkg/operator/operator.go b/pkg/operator/operator.go index f058ac611f..de011fdaa2 100644 --- a/pkg/operator/operator.go +++ b/pkg/operator/operator.go @@ -19,8 +19,10 @@ import ( appstatetypes "github.com/replicatedhq/kots/pkg/appstate/types" "github.com/replicatedhq/kots/pkg/binaries" "github.com/replicatedhq/kots/pkg/embeddedcluster" + "github.com/replicatedhq/kots/pkg/filestore" identitydeploy "github.com/replicatedhq/kots/pkg/identity/deploy" identitytypes "github.com/replicatedhq/kots/pkg/identity/types" + "github.com/replicatedhq/kots/pkg/k8sutil" kotsadmobjects "github.com/replicatedhq/kots/pkg/kotsadm/objects" snapshot "github.com/replicatedhq/kots/pkg/kotsadmsnapshot" "github.com/replicatedhq/kots/pkg/kotsutil" @@ -37,15 +39,20 @@ import ( "github.com/replicatedhq/kots/pkg/supportbundle" supportbundletypes "github.com/replicatedhq/kots/pkg/supportbundle/types" "github.com/replicatedhq/kots/pkg/template" + "github.com/replicatedhq/kots/pkg/update" + upgradeservicetask "github.com/replicatedhq/kots/pkg/upgradeservice/task" "github.com/replicatedhq/kots/pkg/util" "github.com/replicatedhq/kotskinds/multitype" velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" corev1 "k8s.io/api/core/v1" kuberneteserrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" serializer "k8s.io/apimachinery/pkg/runtime/serializer/json" + "k8s.io/client-go/informers" "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/cache" ) var ( @@ -94,6 +101,7 @@ func (o *Operator) Start() error { go o.resumeInformers() go o.resumeDeployments() + o.watchDeployments() startLoop(o.restoreLoop, 2) return nil @@ -410,15 +418,6 @@ func (o *Operator) DeployApp(appID string, sequence int64) (deployed bool, deplo return false, errors.Wrap(err, "failed to deploy app") } - if deployed { - go func() { - err = embeddedcluster.MaybeStartClusterUpgrade(context.TODO(), o.store, kotsKinds, app.ID) - if err != nil { - logger.Error(errors.Wrap(err, "failed to start cluster upgrade")) - } - }() - } - return deployed, nil } @@ -901,3 +900,164 @@ func (o *Operator) renderKotsApplicationSpec(app *apptypes.App, sequence int64, return renderedKotsAppSpec, nil } + +func (o *Operator) watchDeployments() { + factory := informers.NewSharedInformerFactoryWithOptions( + o.k8sClientset, + 0, + informers.WithNamespace(util.PodNamespace), + informers.WithTweakListOptions(func(options *metav1.ListOptions) { + options.LabelSelector = labels.SelectorFromSet( + labels.Set{ + "kots.io/deployment": "true", + "kots.io/processed": "false", + }, + ).String() + }), + ) + + cmInformer := factory.Core().V1().ConfigMaps().Informer() + cmInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj interface{}) { + cm := obj.(*corev1.ConfigMap) + if err := o.reconcileDeployment(cm); err != nil { + logger.Error(errors.Wrapf(err, "failed to reconcile deployment in (%s) configmap", cm.Name)) + } + }, + }) + + go cmInformer.Run(context.Background().Done()) +} + +func (o *Operator) reconcileDeployment(cm *corev1.ConfigMap) (finalError error) { + if cm.Data["requires-cluster-upgrade"] == "true" { + // wait for cluster upgrade even if the embedded cluster version doesn't match yet + // in order to continuously report progress to the user + if err := o.waitForClusterUpgrade(cm.Data["app-slug"]); err != nil { + return errors.Wrap(err, "failed to wait for cluster upgrade") + } + } + + // CAUTION: changes to the embedded cluster version field can break backwards compatibility + ecVersion := cm.Data["embedded-cluster-version"] + if ecVersion == "" { + return errors.New("embedded cluster version not found in deployment") + } + if ecVersion != util.EmbeddedClusterVersion() { + logger.Infof("deployment has embedded cluster version (%s) which does not match current embedded cluster version (%s). will not process...", ecVersion, util.EmbeddedClusterVersion()) + return nil + } + + logger.Infof("processing deployment (%s) for app (%s)", cm.Data["version-label"], cm.Data["app-slug"]) + + if err := upgradeservicetask.SetStatusUpgradingApp(cm.Data["app-slug"], ""); err != nil { + return errors.Wrap(err, "failed to set task status to upgrading app") + } + + defer func() { + if finalError == nil { + if err := upgradeservicetask.ClearStatus(cm.Data["app-slug"]); err != nil { + logger.Error(errors.Wrap(err, "failed to clear task status")) + } + } else { + if err := upgradeservicetask.SetStatusUpgradeFailed(cm.Data["app-slug"], finalError.Error()); err != nil { + logger.Error(errors.Wrap(err, "failed to set task status to failed")) + } + } + // ensure deployment gets processed once + cm.Labels["kots.io/processed"] = "true" + _, err := o.k8sClientset.CoreV1().ConfigMaps(cm.Namespace).Update(context.Background(), cm, metav1.UpdateOptions{}) + if err != nil { + logger.Error(errors.Wrap(err, "failed to update configmap")) + } + }() + + appID := cm.Data["app-id"] + source := cm.Data["source"] + + baseSequence, err := strconv.ParseInt(cm.Data["base-sequence"], 10, 64) + if err != nil { + return errors.Wrap(err, "failed to parse base sequence") + } + + skipPreflights, err := strconv.ParseBool(cm.Data["skip-preflights"]) + if err != nil { + return errors.Wrap(err, "failed to parse is skip preflights") + } + + tgzArchive, err := filestore.GetStore().ReadArchive(cm.Data["app-version-archive"]) + if err != nil { + return errors.Wrap(err, "failed to read archive") + } + defer os.RemoveAll(tgzArchive) + + archiveDir, err := os.MkdirTemp("", "kotsadm") + if err != nil { + return errors.Wrap(err, "failed to create temp dir") + } + defer os.RemoveAll(archiveDir) + + if err := util.ExtractTGZArchive(tgzArchive, archiveDir); err != nil { + return errors.Wrap(err, "failed to extract app archive") + } + + sequence, err := o.store.CreateAppVersion(appID, &baseSequence, archiveDir, source, skipPreflights, render.Renderer{}) + if err != nil { + return errors.Wrap(err, "failed to create app version") + } + + if cm.Data["is-airgap"] == "true" { + if err := update.RemoveAirgapUpdate(cm.Data["app-slug"], cm.Data["channel-id"], cm.Data["update-cursor"]); err != nil { + return errors.Wrap(err, "failed to remove airgap update") + } + } + + if err := filestore.GetStore().DeleteArchive(cm.Data["app-version-archive"]); err != nil { + return errors.Wrap(err, "failed to delete deployment archive") + } + + if pr := cm.Data["preflight-result"]; pr != "" { + if err := o.store.SetPreflightResults(appID, sequence, []byte(pr)); err != nil { + return errors.Wrap(err, "failed to set preflight results") + } + } + + if err := o.store.SetAppChannelChanged(appID, false); err != nil { + return errors.Wrap(err, "failed to reset channel changed flag") + } + + if err := o.store.MarkAsCurrentDownstreamVersion(appID, sequence); err != nil { + return errors.Wrap(err, "failed to mark as current downstream version") + } + + go o.DeployApp(appID, sequence) + + return nil +} + +func (o *Operator) waitForClusterUpgrade(appSlug string) error { + kbClient, err := k8sutil.GetKubeClient(context.Background()) + if err != nil { + return errors.Wrap(err, "failed to get kube client") + } + logger.Infof("waiting for cluster upgrade to finish") + for { + ins, err := embeddedcluster.GetCurrentInstallation(context.Background(), kbClient) + if err != nil { + return errors.Wrap(err, "failed to wait for embedded cluster installation") + } + if embeddedcluster.InstallationSucceeded(context.Background(), ins) { + return nil + } + if embeddedcluster.InstallationFailed(context.Background(), ins) { + if err := upgradeservicetask.SetStatusUpgradeFailed(appSlug, ins.Status.Reason); err != nil { + return errors.Wrap(err, "failed to set task status to failed") + } + return nil // we try to deploy the app even if the cluster upgrade failed + } + if err := upgradeservicetask.SetStatusUpgradingCluster(appSlug, ins.Status.State); err != nil { + return errors.Wrap(err, "failed to set task status to upgrading cluster") + } + time.Sleep(5 * time.Second) + } +} diff --git a/pkg/persistence/mock_db.go b/pkg/persistence/mock_db.go deleted file mode 100644 index 66bc04318b..0000000000 --- a/pkg/persistence/mock_db.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build testing -// +build testing - -package persistence - -import "github.com/rqlite/gorqlite" - -func MustGetDBSession() *gorqlite.Connection { - return db -} diff --git a/pkg/persistence/persistence.go b/pkg/persistence/persistence.go index 9a415c298f..516522c21a 100644 --- a/pkg/persistence/persistence.go +++ b/pkg/persistence/persistence.go @@ -1,12 +1,14 @@ package persistence -import "github.com/rqlite/gorqlite" +import ( + "fmt" + "os" -var ( - db *gorqlite.Connection - uri string + "github.com/rqlite/gorqlite" ) +var db *gorqlite.Connection + func IsInitialized() bool { return db != nil } @@ -15,7 +17,15 @@ func SetDB(database *gorqlite.Connection) { db = database } -func InitDB(databaseUri string) { - uri = databaseUri - MustGetDBSession() +func MustGetDBSession() *gorqlite.Connection { + if db != nil { + return db + } + newDB, err := gorqlite.Open(os.Getenv("RQLITE_URI")) + if err != nil { + fmt.Printf("error connecting to rqlite: %v\n", err) + panic(err) + } + db = &newDB + return db } diff --git a/pkg/persistence/rqlite.go b/pkg/persistence/rqlite.go deleted file mode 100644 index 0e600ce019..0000000000 --- a/pkg/persistence/rqlite.go +++ /dev/null @@ -1,23 +0,0 @@ -//go:build !testing - -package persistence - -import ( - "fmt" - - "github.com/rqlite/gorqlite" -) - -func MustGetDBSession() *gorqlite.Connection { - if db != nil { - return db - } - newDB, err := gorqlite.Open(uri) - if err != nil { - fmt.Printf("error connecting to rqlite: %v\n", err) - panic(err) - } - - db = &newDB - return db -} diff --git a/pkg/preflight/execute.go b/pkg/preflight/execute.go index 4b73d70648..e54132945d 100644 --- a/pkg/preflight/execute.go +++ b/pkg/preflight/execute.go @@ -1,7 +1,6 @@ package preflight import ( - "encoding/json" "strings" "sync" "time" @@ -10,45 +9,16 @@ import ( "github.com/replicatedhq/kots/pkg/k8sutil" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/preflight/types" - "github.com/replicatedhq/kots/pkg/store" troubleshootanalyze "github.com/replicatedhq/troubleshoot/pkg/analyze" troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" troubleshootcollect "github.com/replicatedhq/troubleshoot/pkg/collect" "github.com/replicatedhq/troubleshoot/pkg/preflight" troubleshootpreflight "github.com/replicatedhq/troubleshoot/pkg/preflight" - "go.uber.org/zap" ) -func setPreflightResult(appID string, sequence int64, preflightResults *types.PreflightResults, preflightRunError error) error { - if preflightRunError != nil { - if preflightResults.Errors == nil { - preflightResults.Errors = []*types.PreflightError{} - } - preflightResults.Errors = append(preflightResults.Errors, &types.PreflightError{ - Error: preflightRunError.Error(), - IsRBAC: false, - }) - } - - b, err := json.Marshal(preflightResults) - if err != nil { - return errors.Wrap(err, "failed to marshal preflight results") - } - - if err := store.GetStore().SetPreflightResults(appID, sequence, b); err != nil { - return errors.Wrap(err, "failed to set preflight results") - } - - return nil -} - -// execute will execute the preflights using spec in preflightSpec. +// Execute will Execute the preflights using spec in preflightSpec. // This spec should be rendered, no template functions remaining -func execute(appID string, sequence int64, preflightSpec *troubleshootv1beta2.Preflight, ignorePermissionErrors bool) (*types.PreflightResults, error) { - logger.Info("executing preflight checks", - zap.String("appID", appID), - zap.Int64("sequence", sequence)) - +func Execute(preflightSpec *troubleshootv1beta2.Preflight, ignorePermissionErrors bool, setProgress func(progress map[string]interface{}) error, setResults func(results *types.PreflightResults) error) (*types.PreflightResults, error) { progressChan := make(chan interface{}, 0) // non-zero buffer will result in missed messages defer close(progressChan) @@ -73,26 +43,25 @@ func execute(appID string, sequence int64, preflightSpec *troubleshootv1beta2.Pr } } - progress, ok := msg.(preflight.CollectProgress) + collectProgress, ok := msg.(preflight.CollectProgress) if !ok { continue } // TODO: We need a nice title to display - progressBytes, err := json.Marshal(map[string]interface{}{ - "completedCount": progress.CompletedCount, - "totalCount": progress.TotalCount, - "currentName": progress.CurrentName, - "currentStatus": progress.CurrentStatus, + progress := map[string]interface{}{ + "completedCount": collectProgress.CompletedCount, + "totalCount": collectProgress.TotalCount, + "currentName": collectProgress.CurrentName, + "currentStatus": collectProgress.CurrentStatus, "updatedAt": time.Now().Format(time.RFC3339), - }) - if err != nil { - continue } completeMx.Lock() if !isComplete { - _ = store.GetStore().SetPreflightProgress(appID, sequence, string(progressBytes)) + if err := setProgress(progress); err != nil { + logger.Error(errors.Wrap(err, "failed to set preflight progress")) + } } completeMx.Unlock() } @@ -104,7 +73,17 @@ func execute(appID string, sequence int64, preflightSpec *troubleshootv1beta2.Pr defer completeMx.Unlock() isComplete = true - if err := setPreflightResult(appID, sequence, uploadPreflightResults, preflightRunError); err != nil { + + if preflightRunError != nil { + if uploadPreflightResults.Errors == nil { + uploadPreflightResults.Errors = []*types.PreflightError{} + } + uploadPreflightResults.Errors = append(uploadPreflightResults.Errors, &types.PreflightError{ + Error: preflightRunError.Error(), + IsRBAC: false, + }) + } + if err := setResults(uploadPreflightResults); err != nil { logger.Error(errors.Wrap(err, "failed to set preflight results")) return } diff --git a/pkg/preflight/preflight.go b/pkg/preflight/preflight.go index d74bf44bf9..b5888ceba3 100644 --- a/pkg/preflight/preflight.go +++ b/pkg/preflight/preflight.go @@ -3,6 +3,7 @@ package preflight import ( "bytes" "context" + "encoding/json" "fmt" "os" "path/filepath" @@ -90,7 +91,7 @@ func Run(appID string, appSlug string, sequence int64, isAirgap bool, ignoreNonS preflight = troubleshootpreflight.ConcatPreflightSpec(preflight, &v) } - injectDefaultPreflights(preflight, kotsKinds, registrySettings) + InjectDefaultPreflights(preflight, kotsKinds, registrySettings) numAnalyzers := 0 for _, analyzer := range preflight.Spec.Analyzers { @@ -125,7 +126,7 @@ func Run(appID string, appSlug string, sequence int64, isAirgap bool, ignoreNonS return errors.Wrap(err, "failed to load rendered preflight") } - injectDefaultPreflights(preflight, kotsKinds, registrySettings) + InjectDefaultPreflights(preflight, kotsKinds, registrySettings) numAnalyzers := 0 for _, analyzer := range preflight.Spec.Analyzers { @@ -141,8 +142,15 @@ func Run(appID string, appSlug string, sequence int64, isAirgap bool, ignoreNonS var preflightErr error defer func() { if preflightErr != nil { - err := setPreflightResult(appID, sequence, &types.PreflightResults{}, preflightErr) - if err != nil { + preflightResults := &types.PreflightResults{ + Errors: []*types.PreflightError{ + &types.PreflightError{ + Error: preflightErr.Error(), + IsRBAC: false, + }, + }, + } + if err := setPreflightResults(appID, sequence, preflightResults); err != nil { logger.Error(errors.Wrap(err, "failed to set preflight results")) return } @@ -170,8 +178,17 @@ func Run(appID string, appSlug string, sequence int64, isAirgap bool, ignoreNonS preflight.Spec.Collectors = collectors go func() { - logger.Info("preflight checks beginning") - uploadPreflightResults, err := execute(appID, sequence, preflight, ignoreRBAC) + logger.Info("preflight checks beginning", + zap.String("appID", appID), + zap.Int64("sequence", sequence)) + + setProgress := func(progress map[string]interface{}) error { + return setPreflightProgress(appID, sequence, progress) + } + setResults := func(results *types.PreflightResults) error { + return setPreflightResults(appID, sequence, results) + } + uploadPreflightResults, err := Execute(preflight, ignoreRBAC, setProgress, setResults) if err != nil { logger.Error(errors.Wrap(err, "failed to run preflight checks")) return @@ -226,6 +243,28 @@ func Run(appID string, appSlug string, sequence int64, isAirgap bool, ignoreNonS return nil } +func setPreflightProgress(appID string, sequence int64, progress map[string]interface{}) error { + b, err := json.Marshal(progress) + if err != nil { + return errors.Wrap(err, "failed to marshal preflight progress") + } + if err := store.GetStore().SetPreflightProgress(appID, sequence, string(b)); err != nil { + return errors.Wrap(err, "failed to set preflight progress") + } + return nil +} + +func setPreflightResults(appID string, sequence int64, preflightResults *types.PreflightResults) error { + b, err := json.Marshal(preflightResults) + if err != nil { + return errors.Wrap(err, "failed to marshal preflight results") + } + if err := store.GetStore().SetPreflightResults(appID, sequence, b); err != nil { + return errors.Wrap(err, "failed to set preflight results") + } + return nil +} + // GetPreflightCheckState returns the state of a single preflight check result func GetPreflightCheckState(p *troubleshootpreflight.UploadPreflightResult) string { if p == nil { @@ -369,7 +408,7 @@ func CreateRenderedSpec(app *apptypes.App, sequence int64, origin string, inClus return errors.Wrap(err, "failed to get registry settings for app") } - injectDefaultPreflights(builtPreflight, kotsKinds, registrySettings) + InjectDefaultPreflights(builtPreflight, kotsKinds, registrySettings) collectors, err := registry.UpdateCollectorSpecsWithRegistryData(builtPreflight.Spec.Collectors, registrySettings, kotsKinds.Installation, kotsKinds.License, &kotsKinds.KotsApplication) if err != nil { @@ -446,7 +485,7 @@ func CreateRenderedSpec(app *apptypes.App, sequence int64, origin string, inClus return nil } -func injectDefaultPreflights(preflight *troubleshootv1beta2.Preflight, kotskinds *kotsutil.KotsKinds, registrySettings registrytypes.RegistrySettings) { +func InjectDefaultPreflights(preflight *troubleshootv1beta2.Preflight, kotskinds *kotsutil.KotsKinds, registrySettings registrytypes.RegistrySettings) { if registrySettings.IsValid() && registrySettings.IsReadOnly { // Get images from Installation.KnownImages, see UpdateCollectorSpecsWithRegistryData images := []string{} diff --git a/pkg/pull/pull.go b/pkg/pull/pull.go index 8370ec85a6..65020d7140 100644 --- a/pkg/pull/pull.go +++ b/pkg/pull/pull.go @@ -776,7 +776,7 @@ func ParseIdentityConfigFromFile(filename string) (*kotsv1beta1.IdentityConfig, } func GetAppMetadataFromAirgap(airgapArchive string) (*replicatedapp.ApplicationMetadata, error) { - appArchive, err := archives.GetFileFromAirgap("app.tar.gz", airgapArchive) + appArchive, err := archives.GetFileContentFromTGZArchive("app.tar.gz", airgapArchive) if err != nil { return nil, errors.Wrap(err, "failed to extract app archive") } diff --git a/pkg/registry/images.go b/pkg/registry/images.go index 3dac1a5f08..97e98ad396 100644 --- a/pkg/registry/images.go +++ b/pkg/registry/images.go @@ -28,6 +28,7 @@ import ( "github.com/replicatedhq/kots/pkg/registry/types" kotss3 "github.com/replicatedhq/kots/pkg/s3" "github.com/replicatedhq/kots/pkg/store" + "github.com/replicatedhq/kots/pkg/tasks" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -198,7 +199,7 @@ func deleteUnusedImages(ctx context.Context, registry types.RegistrySettings, us return nil } - currentStatus, _, err := store.GetStore().GetTaskStatus(deleteImagesTaskID) + currentStatus, _, err := tasks.GetTaskStatus(deleteImagesTaskID) if err != nil { return errors.Wrap(err, "failed to get task status") } @@ -208,7 +209,7 @@ func deleteUnusedImages(ctx context.Context, registry types.RegistrySettings, us return nil } - if err := store.GetStore().SetTaskStatus(deleteImagesTaskID, "Searching registry...", "running"); err != nil { + if err := tasks.SetTaskStatus(deleteImagesTaskID, "Searching registry...", "running"); err != nil { return errors.Wrap(err, "failed to set task status") } @@ -344,11 +345,11 @@ func startDeleteImagesTaskMonitor(finishedChan <-chan error) { var finalError error defer func() { if finalError == nil { - if err := store.GetStore().ClearTaskStatus(deleteImagesTaskID); err != nil { + if err := tasks.ClearTaskStatus(deleteImagesTaskID); err != nil { logger.Error(errors.Wrapf(err, "failed to clear %q task status", deleteImagesTaskID)) } } else { - if err := store.GetStore().SetTaskStatus(deleteImagesTaskID, finalError.Error(), "failed"); err != nil { + if err := tasks.SetTaskStatus(deleteImagesTaskID, finalError.Error(), "failed"); err != nil { logger.Error(errors.Wrapf(err, "failed to set error on %q task status", deleteImagesTaskID)) } } @@ -356,8 +357,8 @@ func startDeleteImagesTaskMonitor(finishedChan <-chan error) { for { select { - case <-time.After(time.Second): - if err := store.GetStore().UpdateTaskStatusTimestamp(deleteImagesTaskID); err != nil { + case <-time.After(time.Second * 2): + if err := tasks.UpdateTaskStatusTimestamp(deleteImagesTaskID); err != nil { logger.Error(err) } case err := <-finishedChan: diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 7c72b3cc11..c7f28c14b6 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -19,6 +19,7 @@ import ( "github.com/replicatedhq/kots/pkg/reporting" "github.com/replicatedhq/kots/pkg/rewrite" "github.com/replicatedhq/kots/pkg/store" + "github.com/replicatedhq/kots/pkg/tasks" "github.com/replicatedhq/kots/pkg/util" ) @@ -26,7 +27,7 @@ import ( // and create a new version of the application // the caller is responsible for deleting the appDir returned func RewriteImages(appID string, sequence int64, hostname string, username string, password string, namespace string, isReadOnly bool) (appDir string, finalError error) { - if err := store.GetStore().SetTaskStatus("image-rewrite", "Updating registry settings", "running"); err != nil { + if err := tasks.SetTaskStatus("image-rewrite", "Updating registry settings", "running"); err != nil { return "", errors.Wrap(err, "failed to set task status") } @@ -35,8 +36,8 @@ func RewriteImages(appID string, sequence int64, hostname string, username strin go func() { for { select { - case <-time.After(time.Second): - if err := store.GetStore().UpdateTaskStatusTimestamp("image-rewrite"); err != nil { + case <-time.After(time.Second * 2): + if err := tasks.UpdateTaskStatusTimestamp("image-rewrite"); err != nil { logger.Error(err) } case <-finishedCh: @@ -47,13 +48,13 @@ func RewriteImages(appID string, sequence int64, hostname string, username strin defer func() { if finalError == nil { - if err := store.GetStore().ClearTaskStatus("image-rewrite"); err != nil { + if err := tasks.ClearTaskStatus("image-rewrite"); err != nil { logger.Error(errors.Wrap(err, "failed to clear image rewrite task status")) } } else { // do not show the stack trace to the user causeErr := errors.Cause(finalError) - if err := store.GetStore().SetTaskStatus("image-rewrite", causeErr.Error(), "failed"); err != nil { + if err := tasks.SetTaskStatus("image-rewrite", causeErr.Error(), "failed"); err != nil { logger.Error(errors.Wrap(err, "failed to set image rewrite task status as failed")) } } @@ -97,7 +98,7 @@ func RewriteImages(appID string, sequence int64, hostname string, username strin go func() { scanner := bufio.NewScanner(pipeReader) for scanner.Scan() { - if err := store.GetStore().SetTaskStatus("image-rewrite", scanner.Text(), "running"); err != nil { + if err := tasks.SetTaskStatus("image-rewrite", scanner.Text(), "running"); err != nil { logger.Error(err) } } diff --git a/pkg/render/render.go b/pkg/render/render.go index d63e10ae14..a5bb8cc3d4 100644 --- a/pkg/render/render.go +++ b/pkg/render/render.go @@ -10,7 +10,6 @@ import ( "github.com/replicatedhq/kots/pkg/kotsutil" registrytypes "github.com/replicatedhq/kots/pkg/registry/types" types "github.com/replicatedhq/kots/pkg/render/types" - "github.com/replicatedhq/kots/pkg/reporting" "github.com/replicatedhq/kots/pkg/rewrite" "github.com/replicatedhq/kots/pkg/template" "github.com/replicatedhq/kots/pkg/util" @@ -145,7 +144,7 @@ func RenderDir(opts types.RenderDirOptions) error { AppSlug: opts.App.Slug, IsGitOps: opts.App.IsGitOps, AppSequence: opts.Sequence, - ReportingInfo: reporting.GetReportingInfo(opts.App.ID), + ReportingInfo: opts.ReportingInfo, RegistrySettings: opts.RegistrySettings, // TODO: pass in as arguments if this is ever called from CLI diff --git a/pkg/render/types/interface.go b/pkg/render/types/interface.go index 8bc5d85808..a56ce9fb99 100644 --- a/pkg/render/types/interface.go +++ b/pkg/render/types/interface.go @@ -2,6 +2,7 @@ package types import ( downstreamtypes "github.com/replicatedhq/kots/pkg/api/downstream/types" + reportingtypes "github.com/replicatedhq/kots/pkg/api/reporting/types" apptypes "github.com/replicatedhq/kots/pkg/app/types" "github.com/replicatedhq/kots/pkg/kotsutil" registrytypes "github.com/replicatedhq/kots/pkg/registry/types" @@ -23,6 +24,7 @@ type RenderDirOptions struct { Downstreams []downstreamtypes.Downstream RegistrySettings registrytypes.RegistrySettings Sequence int64 + ReportingInfo *reportingtypes.ReportingInfo } type Renderer interface { diff --git a/pkg/replicatedapp/api.go b/pkg/replicatedapp/api.go index 5d1863e3a8..fa7662f3c6 100644 --- a/pkg/replicatedapp/api.go +++ b/pkg/replicatedapp/api.go @@ -79,7 +79,7 @@ func getLicenseFromAPI(url string, licenseID string) (*LicenseData, error) { req.SetBasicAuth(licenseID, licenseID) - if persistence.IsInitialized() { + if persistence.IsInitialized() && !util.IsUpgradeService() { appId, err := getAppIdFromLicenseId(store.GetStore(), licenseID) if err != nil { return nil, errors.Wrap(err, "failed to get license by id") diff --git a/pkg/replicatedapp/embeddedcluster.go b/pkg/replicatedapp/embeddedcluster.go new file mode 100644 index 0000000000..543683ab25 --- /dev/null +++ b/pkg/replicatedapp/embeddedcluster.go @@ -0,0 +1,116 @@ +package replicatedapp + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/util" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" +) + +func GetECVersionForRelease(license *kotsv1beta1.License, versionLabel string) (string, error) { + url := fmt.Sprintf("%s/clusterconfig/version/Installer?versionLabel=%s", license.Spec.Endpoint, versionLabel) + req, err := util.NewRequest("GET", url, nil) + if err != nil { + return "", errors.Wrap(err, "failed to call newrequest") + } + + req.SetBasicAuth(license.Spec.LicenseID, license.Spec.LicenseID) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", errors.Wrap(err, "failed to execute request") + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", errors.Errorf("unexpected status code %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", errors.Wrap(err, "failed to read body") + } + + response := struct { + Version string `json:"version"` + }{} + if err := json.Unmarshal(body, &response); err != nil { + return "", errors.Wrap(err, "failed to unmarshal response") + } + + return response.Version, nil +} + +func DownloadKOTSBinary(license *kotsv1beta1.License, versionLabel string) (string, error) { + url := fmt.Sprintf("%s/clusterconfig/artifact/kots?versionLabel=%s", license.Spec.Endpoint, versionLabel) + req, err := util.NewRequest("GET", url, nil) + if err != nil { + return "", errors.Wrap(err, "failed to call newrequest") + } + + req.SetBasicAuth(license.Spec.LicenseID, license.Spec.LicenseID) + + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + req.Header.Del("Authorization") + return nil + }, + } + resp, err := client.Do(req) + if err != nil { + return "", errors.Wrap(err, "failed to execute request") + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", errors.Errorf("unexpected status code %d", resp.StatusCode) + } + + tmpFile, err := os.CreateTemp("", "kotsbin") + if err != nil { + return "", errors.Wrap(err, "failed to create temp file") + } + defer tmpFile.Close() + + gzipReader, err := gzip.NewReader(resp.Body) + if err != nil { + return "", errors.Wrap(err, "failed to get new gzip reader") + } + defer gzipReader.Close() + + tarReader := tar.NewReader(gzipReader) + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return "", errors.Wrap(err, "failed to get read archive") + } + + if header.Typeflag != tar.TypeReg { + continue + } + if header.Name != "kots" { + continue + } + + if _, err := io.Copy(tmpFile, tarReader); err != nil { + return "", errors.Wrap(err, "failed to copy kots binary") + } + if err := os.Chmod(tmpFile.Name(), 0755); err != nil { + return "", errors.Wrap(err, "failed to set file permissions") + } + + return tmpFile.Name(), nil + } + + return "", errors.New("kots binary not found in archive") +} diff --git a/pkg/replicatedapp/upstream.go b/pkg/replicatedapp/upstream.go index d4baf20612..c96aa418b1 100644 --- a/pkg/replicatedapp/upstream.go +++ b/pkg/replicatedapp/upstream.go @@ -17,7 +17,6 @@ type ReplicatedUpstream struct { Channel *string AppSlug string VersionLabel *string - Sequence *int } func ParseReplicatedURL(u *url.URL) (*ReplicatedUpstream, error) { diff --git a/pkg/reporting/util.go b/pkg/reporting/util.go index 7cd2f5d503..1db80e9c03 100644 --- a/pkg/reporting/util.go +++ b/pkg/reporting/util.go @@ -24,6 +24,7 @@ func GetReportingInfoHeaders(reportingInfo *types.ReportingInfo) map[string]stri return headers } + headers["User-Agent"] = reportingInfo.UserAgent headers["X-Replicated-K8sVersion"] = reportingInfo.K8sVersion headers["X-Replicated-IsKurl"] = strconv.FormatBool(reportingInfo.IsKurl) headers["X-Replicated-AppStatus"] = reportingInfo.AppStatus diff --git a/pkg/rewrite/rewrite.go b/pkg/rewrite/rewrite.go index 95dde7e9cd..edfaceeb27 100644 --- a/pkg/rewrite/rewrite.go +++ b/pkg/rewrite/rewrite.go @@ -21,7 +21,6 @@ import ( "github.com/replicatedhq/kots/pkg/midstream" registrytypes "github.com/replicatedhq/kots/pkg/registry/types" "github.com/replicatedhq/kots/pkg/rendered" - "github.com/replicatedhq/kots/pkg/store" "github.com/replicatedhq/kots/pkg/upstream" upstreamtypes "github.com/replicatedhq/kots/pkg/upstream/types" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" @@ -311,10 +310,6 @@ func Rewrite(rewriteOptions RewriteOptions) error { return errors.Wrap(err, "failed to write downstreams") } - if err := store.GetStore().UpdateAppVersionInstallationSpec(rewriteOptions.AppID, rewriteOptions.AppSequence, renderedKotsKinds.Installation); err != nil { - return errors.Wrap(err, "failed to update installation spec") - } - if err := rendered.WriteRenderedApp(&rendered.WriteOptions{ BaseDir: u.GetBaseDir(writeUpstreamOptions), OverlaysDir: u.GetOverlaysDir(writeUpstreamOptions), diff --git a/pkg/store/kotsstore/airgap_store.go b/pkg/store/kotsstore/airgap_store.go index baca30276d..3e6a013c16 100644 --- a/pkg/store/kotsstore/airgap_store.go +++ b/pkg/store/kotsstore/airgap_store.go @@ -7,6 +7,7 @@ import ( "github.com/replicatedhq/kots/pkg/airgap/types" airgaptypes "github.com/replicatedhq/kots/pkg/airgap/types" "github.com/replicatedhq/kots/pkg/persistence" + "github.com/replicatedhq/kots/pkg/tasks" "github.com/rqlite/gorqlite" ) @@ -71,7 +72,7 @@ func (s *KOTSStore) GetAirgapInstallStatus(appID string) (*airgaptypes.InstallSt return nil, errors.Wrap(err, "failed to scan") } - _, message, err := s.GetTaskStatus(fmt.Sprintf("airgap-install-slug-%s", slug)) + _, message, err := tasks.GetTaskStatus(fmt.Sprintf("airgap-install-slug-%s", slug)) if err != nil { return nil, errors.Wrap(err, "failed to get task status") } diff --git a/pkg/store/kotsstore/downstream_store.go b/pkg/store/kotsstore/downstream_store.go index 345fad66d8..1a87320104 100644 --- a/pkg/store/kotsstore/downstream_store.go +++ b/pkg/store/kotsstore/downstream_store.go @@ -14,6 +14,7 @@ import ( "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/persistence" "github.com/replicatedhq/kots/pkg/store/types" + "github.com/replicatedhq/kots/pkg/tasks" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/rqlite/gorqlite" ) @@ -613,7 +614,7 @@ func (s *KOTSStore) AddDownstreamVersionsDetails(appID string, clusterID string, if version.Status == types.VersionPendingDownload { downloadTaskID := fmt.Sprintf("update-download.%d", version.Sequence) - downloadStatus, downloadStatusMessage, err := s.GetTaskStatus(downloadTaskID) + downloadStatus, downloadStatusMessage, err := tasks.GetTaskStatus(downloadTaskID) if err != nil { // don't fail on this logger.Error(errors.Wrap(err, fmt.Sprintf("failed to get %s task status", downloadTaskID))) diff --git a/pkg/store/kotsstore/embedded_cluster_store.go b/pkg/store/kotsstore/embedded_cluster_store.go index 5f99d93313..1dd502f267 100644 --- a/pkg/store/kotsstore/embedded_cluster_store.go +++ b/pkg/store/kotsstore/embedded_cluster_store.go @@ -3,7 +3,6 @@ package kotsstore import ( "encoding/json" "fmt" - "time" "github.com/rqlite/gorqlite" @@ -69,40 +68,3 @@ func (s *KOTSStore) GetEmbeddedClusterInstallCommandRoles(token string) ([]strin return rolesArr, nil } - -func (s *KOTSStore) SetEmbeddedClusterState(state string) error { - db := persistence.MustGetDBSession() - query := ` -insert into embedded_cluster_status (updated_at, status) -values (?, ?) -on conflict (updated_at) do update set - status = EXCLUDED.status` - wr, err := db.WriteOneParameterized(gorqlite.ParameterizedStatement{ - Query: query, - Arguments: []interface{}{time.Now().Unix(), state}, - }) - if err != nil { - return fmt.Errorf("failed to write: %w: %v", err, wr.Err) - } - return nil -} - -func (s *KOTSStore) GetEmbeddedClusterState() (string, error) { - db := persistence.MustGetDBSession() - query := `select status from embedded_cluster_status ORDER BY updated_at DESC LIMIT 1` - rows, err := db.QueryOneParameterized(gorqlite.ParameterizedStatement{ - Query: query, - Arguments: []interface{}{}, - }) - if err != nil { - return "", fmt.Errorf("failed to query: %w: %v", err, rows.Err) - } - if !rows.Next() { - return "", nil - } - var state gorqlite.NullString - if err := rows.Scan(&state); err != nil { - return "", fmt.Errorf("failed to scan: %w", err) - } - return state.String, nil -} diff --git a/pkg/store/kotsstore/installation_store.go b/pkg/store/kotsstore/installation_store.go index f53e68b5be..7899aae48a 100644 --- a/pkg/store/kotsstore/installation_store.go +++ b/pkg/store/kotsstore/installation_store.go @@ -6,6 +6,7 @@ import ( "github.com/pkg/errors" installationtypes "github.com/replicatedhq/kots/pkg/online/types" "github.com/replicatedhq/kots/pkg/persistence" + "github.com/replicatedhq/kots/pkg/tasks" "github.com/rqlite/gorqlite" ) @@ -29,7 +30,7 @@ func (s *KOTSStore) GetPendingInstallationStatus() (*installationtypes.InstallSt return nil, errors.Wrap(err, "failed to scan") } - _, message, err := s.GetTaskStatus("online-install") + _, message, err := tasks.GetTaskStatus("online-install") if err != nil { return nil, errors.Wrap(err, "failed to get task status") } diff --git a/pkg/store/kotsstore/kots_store.go b/pkg/store/kotsstore/kots_store.go index 6d4f234421..e8caf43d36 100644 --- a/pkg/store/kotsstore/kots_store.go +++ b/pkg/store/kotsstore/kots_store.go @@ -28,16 +28,9 @@ var ( ErrNotFound = errors.New("not found") ) -type cachedTaskStatus struct { - expirationTime time.Time - taskStatus TaskStatus -} - type KOTSStore struct { sessionSecret *corev1.Secret sessionExpiration time.Time - - cachedTaskStatus map[string]*cachedTaskStatus } func init() { @@ -164,9 +157,7 @@ func canIgnoreEtcdError(err error) bool { } func StoreFromEnv() *KOTSStore { - return &KOTSStore{ - cachedTaskStatus: make(map[string]*cachedTaskStatus), - } + return &KOTSStore{} } func (s *KOTSStore) getConfigmap(name string) (*corev1.ConfigMap, error) { diff --git a/pkg/store/kotsstore/license_store.go b/pkg/store/kotsstore/license_store.go index bcf32fb9fc..3c2477c1d1 100644 --- a/pkg/store/kotsstore/license_store.go +++ b/pkg/store/kotsstore/license_store.go @@ -8,7 +8,7 @@ import ( "time" "github.com/pkg/errors" - gitopstypes "github.com/replicatedhq/kots/pkg/gitops/types" + reportingtypes "github.com/replicatedhq/kots/pkg/api/reporting/types" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/persistence" rendertypes "github.com/replicatedhq/kots/pkg/render/types" @@ -106,7 +106,7 @@ func (s *KOTSStore) GetAllAppLicenses() ([]*kotsv1beta1.License, error) { return licenses, nil } -func (s *KOTSStore) UpdateAppLicense(appID string, baseSequence int64, archiveDir string, newLicense *kotsv1beta1.License, originalLicenseData string, channelChanged bool, failOnVersionCreate bool, gitops gitopstypes.DownstreamGitOps, renderer rendertypes.Renderer) (int64, error) { +func (s *KOTSStore) UpdateAppLicense(appID string, baseSequence int64, archiveDir string, newLicense *kotsv1beta1.License, originalLicenseData string, channelChanged bool, failOnVersionCreate bool, renderer rendertypes.Renderer, reportingInfo *reportingtypes.ReportingInfo) (int64, error) { db := persistence.MustGetDBSession() statements := []gorqlite.ParameterizedStatement{} @@ -127,7 +127,7 @@ func (s *KOTSStore) UpdateAppLicense(appID string, baseSequence int64, archiveDi Arguments: []interface{}{originalLicenseData, time.Now().Unix(), channelChanged, appID}, }) - appVersionStatements, newSeq, err := s.createNewVersionForLicenseChangeStatements(appID, baseSequence, archiveDir, gitops, renderer) + appVersionStatements, newSeq, err := s.createNewVersionForLicenseChangeStatements(appID, baseSequence, archiveDir, renderer, reportingInfo) if err != nil { // ignore error here to prevent a failure to render the current version // preventing the end-user from updating the application @@ -164,7 +164,7 @@ func (s *KOTSStore) UpdateAppLicenseSyncNow(appID string) error { return nil } -func (s *KOTSStore) createNewVersionForLicenseChangeStatements(appID string, baseSequence int64, archiveDir string, gitops gitopstypes.DownstreamGitOps, renderer rendertypes.Renderer) ([]gorqlite.ParameterizedStatement, int64, error) { +func (s *KOTSStore) createNewVersionForLicenseChangeStatements(appID string, baseSequence int64, archiveDir string, renderer rendertypes.Renderer, reportingInfo *reportingtypes.ReportingInfo) ([]gorqlite.ParameterizedStatement, int64, error) { registrySettings, err := s.GetRegistryDetailsForApp(appID) if err != nil { return nil, int64(0), errors.Wrap(err, "failed to get registry settings for app") @@ -191,11 +191,12 @@ func (s *KOTSStore) createNewVersionForLicenseChangeStatements(appID string, bas Downstreams: downstreams, RegistrySettings: registrySettings, Sequence: nextAppSequence, + ReportingInfo: reportingInfo, }); err != nil { return nil, int64(0), errors.Wrap(err, "failed to render new version") } - appVersionStatements, newSequence, err := s.createAppVersionStatements(appID, &baseSequence, archiveDir, "License Change", false, gitops, renderer) + appVersionStatements, newSequence, err := s.createAppVersionStatements(appID, &baseSequence, archiveDir, "License Change", false, renderer) if err != nil { return nil, int64(0), errors.Wrap(err, "failed to construct app version statements") } diff --git a/pkg/store/kotsstore/migrations.go b/pkg/store/kotsstore/migrations.go index 1b414e3953..9cd1057d59 100644 --- a/pkg/store/kotsstore/migrations.go +++ b/pkg/store/kotsstore/migrations.go @@ -48,9 +48,6 @@ func (s *KOTSStore) RunMigrations() { if err := s.migrateSupportBundlesFromRqlite(); err != nil { logger.Error(errors.Wrap(err, "failed to migrate support bundles")) } - if err := s.migrateTasksFromRqlite(); err != nil { - logger.Error(errors.Wrap(err, "failed to migrate tasks")) - } } func (s *KOTSStore) migrateKotsAppSpec() error { diff --git a/pkg/store/kotsstore/task_store.go b/pkg/store/kotsstore/task_store.go deleted file mode 100644 index 7a2db0babe..0000000000 --- a/pkg/store/kotsstore/task_store.go +++ /dev/null @@ -1,254 +0,0 @@ -package kotsstore - -import ( - "encoding/json" - "fmt" - "sync" - "time" - - "github.com/pkg/errors" - "github.com/replicatedhq/kots/pkg/persistence" - "github.com/rqlite/gorqlite" -) - -const ( - TaskStatusConfigMapName = `kotsadm-tasks` - ConfgConfigMapName = `kotsadm-confg` - - taskCacheTTL = 1 * time.Minute -) - -var ( - taskStatusLock = sync.Mutex{} -) - -type TaskStatus struct { - Message string `json:"message"` - Status string `json:"status"` - UpdatedAt time.Time `json:"updatedAt"` -} - -func (s *KOTSStore) migrateTasksFromRqlite() error { - db := persistence.MustGetDBSession() - - query := `select updated_at, current_message, status from api_task_status` - rows, err := db.QueryOne(query) - if err != nil { - return fmt.Errorf("failed to select tasks for migration: %v: %v", err, rows.Err) - } - - taskCm, err := s.getConfigmap(TaskStatusConfigMapName) - if err != nil { - return errors.Wrap(err, "failed to get task status configmap") - } - - if taskCm.Data == nil { - taskCm.Data = map[string]string{} - } - - for rows.Next() { - var id string - var status gorqlite.NullString - var message gorqlite.NullString - - ts := TaskStatus{} - if err := rows.Scan(&id, &ts.UpdatedAt, &message, &status); err != nil { - return errors.Wrap(err, "failed to scan task status") - } - - if status.Valid { - ts.Status = status.String - } - if message.Valid { - ts.Message = message.String - } - - b, err := json.Marshal(ts) - if err != nil { - return errors.Wrap(err, "failed to marshal task status") - } - - taskCm.Data[id] = string(b) - } - - if err := s.updateConfigmap(taskCm); err != nil { - return errors.Wrap(err, "failed to update task status configmap") - } - - query = `delete from api_task_status` - if wr, err := db.WriteOne(query); err != nil { - return fmt.Errorf("failed to delete tasks from db: %v: %v", err, wr.Err) - } - - return nil -} - -func (s *KOTSStore) SetTaskStatus(id string, message string, status string) error { - taskStatusLock.Lock() - defer taskStatusLock.Unlock() - - cached := s.cachedTaskStatus[id] - if cached == nil { - cached = &cachedTaskStatus{} - s.cachedTaskStatus[id] = cached - } - cached.taskStatus.Message = message - cached.taskStatus.Status = status - cached.taskStatus.UpdatedAt = time.Now() - cached.expirationTime = time.Now().Add(taskCacheTTL) - - configmap, err := s.getConfigmap(TaskStatusConfigMapName) - if err != nil { - if canIgnoreEtcdError(err) { - return nil - } - return errors.Wrap(err, "failed to get task status configmap") - } - - if configmap.Data == nil { - configmap.Data = map[string]string{} - } - - b, err := json.Marshal(cached.taskStatus) - if err != nil { - return errors.Wrap(err, "failed to marshal task status") - } - - configmap.Data[id] = string(b) - - if err := s.updateConfigmap(configmap); err != nil { - if canIgnoreEtcdError(err) { - return nil - } - return errors.Wrap(err, "failed to update task status configmap") - } - - return nil -} - -func (s *KOTSStore) UpdateTaskStatusTimestamp(id string) error { - taskStatusLock.Lock() - defer taskStatusLock.Unlock() - - cached := s.cachedTaskStatus[id] - if cached != nil { - cached.taskStatus.UpdatedAt = time.Now() - cached.expirationTime = time.Now().Add(taskCacheTTL) - } - - configmap, err := s.getConfigmap(TaskStatusConfigMapName) - if err != nil { - if canIgnoreEtcdError(err) && cached != nil { - return nil - } - return errors.Wrap(err, "failed to get task status configmap") - } - - if configmap.Data == nil { - configmap.Data = map[string]string{} - } - - data, ok := configmap.Data[id] - if !ok { - return nil // copied from s3pgstore - } - - ts := TaskStatus{} - if err := json.Unmarshal([]byte(data), &ts); err != nil { - return errors.Wrap(err, "failed to unmarshal task status") - } - - ts.UpdatedAt = time.Now() - - b, err := json.Marshal(ts) - if err != nil { - return errors.Wrap(err, "failed to marshal task status") - } - - configmap.Data[id] = string(b) - - if err := s.updateConfigmap(configmap); err != nil { - if canIgnoreEtcdError(err) && cached != nil { - return nil - } - return errors.Wrap(err, "failed to update task status configmap") - } - - return nil -} - -func (s *KOTSStore) ClearTaskStatus(id string) error { - taskStatusLock.Lock() - defer taskStatusLock.Unlock() - - defer delete(s.cachedTaskStatus, id) - - configmap, err := s.getConfigmap(TaskStatusConfigMapName) - if err != nil { - return errors.Wrap(err, "failed to get task status configmap") - } - - if configmap.Data == nil { - configmap.Data = map[string]string{} - } - - _, ok := configmap.Data[id] - if !ok { - return nil // copied from s3pgstore - } - - delete(configmap.Data, id) - - if err := s.updateConfigmap(configmap); err != nil { - return errors.Wrap(err, "failed to update task status configmap") - } - - return nil -} - -func (s *KOTSStore) GetTaskStatus(id string) (string, string, error) { - taskStatusLock.Lock() - defer taskStatusLock.Unlock() - - cached := s.cachedTaskStatus[id] - if cached != nil && time.Now().Before(cached.expirationTime) { - return cached.taskStatus.Status, cached.taskStatus.Message, nil - } - - if cached == nil { - cached = &cachedTaskStatus{ - expirationTime: time.Now().Add(taskCacheTTL), - } - s.cachedTaskStatus[id] = cached - } - - configmap, err := s.getConfigmap(TaskStatusConfigMapName) - if err != nil { - if canIgnoreEtcdError(err) && cached != nil { - return cached.taskStatus.Status, cached.taskStatus.Message, nil - } - return "", "", errors.Wrap(err, "failed to get task status configmap") - } - - if configmap.Data == nil { - return "", "", nil - } - - marshalled, ok := configmap.Data[id] - if !ok { - return "", "", nil - } - - ts := TaskStatus{} - if err := json.Unmarshal([]byte(marshalled), &ts); err != nil { - return "", "", errors.Wrap(err, "error unmarshalling task status") - } - - if ts.UpdatedAt.Before(time.Now().Add(-10 * time.Second)) { - return "", "", nil - } - - cached.taskStatus = ts - - return ts.Status, ts.Message, nil -} diff --git a/pkg/store/kotsstore/version_store.go b/pkg/store/kotsstore/version_store.go index 081f38dac5..92492cc581 100644 --- a/pkg/store/kotsstore/version_store.go +++ b/pkg/store/kotsstore/version_store.go @@ -1,7 +1,6 @@ package kotsstore import ( - "bytes" "encoding/base64" "encoding/json" "fmt" @@ -22,7 +21,7 @@ import ( "github.com/replicatedhq/kots/pkg/binaries" "github.com/replicatedhq/kots/pkg/cursor" "github.com/replicatedhq/kots/pkg/filestore" - gitopstypes "github.com/replicatedhq/kots/pkg/gitops/types" + "github.com/replicatedhq/kots/pkg/gitops" "github.com/replicatedhq/kots/pkg/k8sutil" kotsadmconfig "github.com/replicatedhq/kots/pkg/kotsadmconfig" "github.com/replicatedhq/kots/pkg/kotsutil" @@ -38,7 +37,6 @@ import ( "github.com/rqlite/gorqlite" velerov1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - serializer "k8s.io/apimachinery/pkg/runtime/serializer/json" "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/application/api/v1beta1" ) @@ -244,74 +242,6 @@ func (s *KOTSStore) GetEmbeddedClusterConfigForVersion(appID string, sequence in return embeddedClusterConfig, nil } -// CreateAppVersion takes an unarchived app, makes an archive and then uploads it -// to s3 with the appID and sequence specified -func (s *KOTSStore) CreateAppVersionArchive(appID string, sequence int64, archivePath string) error { - paths := []string{ - filepath.Join(archivePath, "upstream"), - } - - basePath := filepath.Join(archivePath, "base") - if _, err := os.Stat(basePath); err == nil { - paths = append(paths, basePath) - } - - overlaysPath := filepath.Join(archivePath, "overlays") - if _, err := os.Stat(overlaysPath); err == nil { - paths = append(paths, overlaysPath) - } - - renderedPath := filepath.Join(archivePath, "rendered") - if _, err := os.Stat(renderedPath); err == nil { - paths = append(paths, renderedPath) - } - - kotsKindsPath := filepath.Join(archivePath, "kotsKinds") - if _, err := os.Stat(kotsKindsPath); err == nil { - paths = append(paths, kotsKindsPath) - } - - helmPath := filepath.Join(archivePath, "helm") - if _, err := os.Stat(helmPath); err == nil { - paths = append(paths, helmPath) - } - - skippedFilesPath := filepath.Join(archivePath, "skippedFiles") - if _, err := os.Stat(skippedFilesPath); err == nil { - paths = append(paths, skippedFilesPath) - } - - tmpDir, err := ioutil.TempDir("", "kotsadm") - if err != nil { - return errors.Wrap(err, "failed to create temp file") - } - defer os.RemoveAll(tmpDir) - fileToUpload := filepath.Join(tmpDir, "archive.tar.gz") - - tarGz := archiver.TarGz{ - Tar: &archiver.Tar{ - ImplicitTopLevelFolder: false, - }, - } - if err := tarGz.Archive(paths, fileToUpload); err != nil { - return errors.Wrap(err, "failed to create archive") - } - - f, err := os.Open(fileToUpload) - if err != nil { - return errors.Wrap(err, "failed to open archive file") - } - defer f.Close() - - outputPath := fmt.Sprintf("%s/%d.tar.gz", appID, sequence) - err = filestore.GetStore().WriteArchive(outputPath, f) - if err != nil { - return errors.Wrap(err, "failed to write archive") - } - - return nil -} - // GetAppVersionArchive will fetch the archive and extract it into the given dstPath directory name func (s *KOTSStore) GetAppVersionArchive(appID string, sequence int64, dstPath string) error { // too noisy @@ -481,7 +411,7 @@ func (s *KOTSStore) CreatePendingDownloadAppVersion(appID string, update upstrea return newSequence, nil } -func (s *KOTSStore) UpdateAppVersion(appID string, sequence int64, baseSequence *int64, filesInDir string, source string, skipPreflights bool, gitops gitopstypes.DownstreamGitOps, renderer rendertypes.Renderer) error { +func (s *KOTSStore) UpdateAppVersion(appID string, sequence int64, baseSequence *int64, filesInDir string, source string, skipPreflights bool, renderer rendertypes.Renderer) error { // make sure version exists first if v, err := s.GetAppVersion(appID, sequence); err != nil { return errors.Wrap(err, "failed to get app version") @@ -491,7 +421,7 @@ func (s *KOTSStore) UpdateAppVersion(appID string, sequence int64, baseSequence db := persistence.MustGetDBSession() - appVersionStatements, err := s.upsertAppVersionStatements(appID, sequence, baseSequence, filesInDir, source, skipPreflights, gitops, renderer) + appVersionStatements, err := s.upsertAppVersionStatements(appID, sequence, baseSequence, filesInDir, source, skipPreflights, renderer) if err != nil { return errors.Wrap(err, "failed to construct app version statements") } @@ -507,10 +437,10 @@ func (s *KOTSStore) UpdateAppVersion(appID string, sequence int64, baseSequence return nil } -func (s *KOTSStore) CreateAppVersion(appID string, baseSequence *int64, filesInDir string, source string, skipPreflights bool, gitops gitopstypes.DownstreamGitOps, renderer rendertypes.Renderer) (int64, error) { +func (s *KOTSStore) CreateAppVersion(appID string, baseSequence *int64, filesInDir string, source string, skipPreflights bool, renderer rendertypes.Renderer) (int64, error) { db := persistence.MustGetDBSession() - appVersionStatements, newSequence, err := s.createAppVersionStatements(appID, baseSequence, filesInDir, source, skipPreflights, gitops, renderer) + appVersionStatements, newSequence, err := s.createAppVersionStatements(appID, baseSequence, filesInDir, source, skipPreflights, renderer) if err != nil { return 0, errors.Wrap(err, "failed to construct app version statements") } @@ -526,13 +456,13 @@ func (s *KOTSStore) CreateAppVersion(appID string, baseSequence *int64, filesInD return newSequence, nil } -func (s *KOTSStore) createAppVersionStatements(appID string, baseSequence *int64, filesInDir string, source string, skipPreflights bool, gitops gitopstypes.DownstreamGitOps, renderer rendertypes.Renderer) ([]gorqlite.ParameterizedStatement, int64, error) { +func (s *KOTSStore) createAppVersionStatements(appID string, baseSequence *int64, filesInDir string, source string, skipPreflights bool, renderer rendertypes.Renderer) ([]gorqlite.ParameterizedStatement, int64, error) { newSequence, err := s.GetNextAppSequence(appID) if err != nil { return nil, 0, errors.Wrap(err, "failed to get next sequence number") } - appVersionStatements, err := s.upsertAppVersionStatements(appID, newSequence, baseSequence, filesInDir, source, skipPreflights, gitops, renderer) + appVersionStatements, err := s.upsertAppVersionStatements(appID, newSequence, baseSequence, filesInDir, source, skipPreflights, renderer) if err != nil { return nil, 0, errors.Wrap(err, "failed to construct app version statements") } @@ -540,7 +470,7 @@ func (s *KOTSStore) createAppVersionStatements(appID string, baseSequence *int64 return appVersionStatements, newSequence, nil } -func (s *KOTSStore) upsertAppVersionStatements(appID string, sequence int64, baseSequence *int64, filesInDir string, source string, skipPreflights bool, gitops gitopstypes.DownstreamGitOps, renderer rendertypes.Renderer) ([]gorqlite.ParameterizedStatement, error) { +func (s *KOTSStore) upsertAppVersionStatements(appID string, sequence int64, baseSequence *int64, filesInDir string, source string, skipPreflights bool, renderer rendertypes.Renderer) ([]gorqlite.ParameterizedStatement, error) { statements := []gorqlite.ParameterizedStatement{} kotsKinds, err := kotsutil.LoadKotsKinds(filesInDir) @@ -590,7 +520,7 @@ func (s *KOTSStore) upsertAppVersionStatements(appID string, sequence int64, bas if err := secrets.ReplaceSecretsInPath(filesInDir, clientset); err != nil { return nil, errors.Wrap(err, "failed to replace secrets") } - if err := s.CreateAppVersionArchive(appID, sequence, filesInDir); err != nil { + if err := apparchive.CreateAppVersionArchive(filesInDir, fmt.Sprintf("%s/%d.tar.gz", appID, sequence)); err != nil { return nil, errors.Wrap(err, "failed to create app version archive") } @@ -611,11 +541,6 @@ func (s *KOTSStore) upsertAppVersionStatements(appID string, sequence int64, bas previousArchiveDir = previousDir } - registrySettings, err := s.GetRegistryDetailsForApp(appID) - if err != nil { - return nil, errors.Wrap(err, "failed to get app registry info") - } - downstreams, err := s.ListDownstreamsForApp(appID) if err != nil { return nil, errors.Wrap(err, "failed to list downstreams") @@ -626,28 +551,9 @@ func (s *KOTSStore) upsertAppVersionStatements(appID string, sequence int64, bas for _, d := range downstreams { // there's a small chance this is not optimal, but no current code path // will support multiple downstreams, so this is cleaner here for now - hasStrictPreflights, err := troubleshootpreflight.HasStrictAnalyzers(renderedPreflight) + downstreamStatus, err := s.determineDownstreamVersionStatus(a, sequence, baseSequence, kotsKinds, skipPreflights) if err != nil { - return nil, errors.Wrap(err, "failed to check strict preflights from spec") - } - downstreamStatus := types.VersionPending - if baseSequence == nil && util.IsEmbeddedCluster() { - // embedded clusters always require cluster management on initial install - downstreamStatus = types.VersionPendingClusterManagement - } else if baseSequence == nil && kotsKinds.IsConfigurable() { // initial version should always require configuration (if exists) even if all required items are already set and have values (except for automated installs, which can override this later) - downstreamStatus = types.VersionPendingConfig - } else if kotsKinds.HasPreflights() && (!skipPreflights || hasStrictPreflights) { - downstreamStatus = types.VersionPendingPreflight - } - if baseSequence != nil { // only check if the version needs configuration for later versions (not the initial one) since the config is always required for the initial version (except for automated installs, which can override that later) - // check if version needs additional configuration - t, err := kotsadmconfig.NeedsConfiguration(a.Slug, sequence, a.IsAirgap, kotsKinds, registrySettings) - if err != nil { - return nil, errors.Wrap(err, "failed to check if version needs configuration") - } - if t { - downstreamStatus = types.VersionPendingConfig - } + return nil, errors.Wrap(err, "failed to determine downstream version status") } diffSummary, diffSummaryError := "", "" @@ -665,7 +571,7 @@ func (s *KOTSStore) upsertAppVersionStatements(appID string, sequence int64, bas } } - commitURL, err := gitops.CreateGitOpsDownstreamCommit(appID, d.ClusterID, int(sequence), filesInDir, d.Name) + commitURL, err := gitops.CreateGitOpsDownstreamCommit(a, d.ClusterID, int(sequence), filesInDir, d.Name) if err != nil { return nil, errors.Wrap(err, "failed to create gitops commit") } @@ -826,6 +732,43 @@ func (s *KOTSStore) upsertAppVersionRecordStatements(appID string, sequence int6 return statements, nil } +func (s *KOTSStore) determineDownstreamVersionStatus(a *apptypes.App, sequence int64, baseSequence *int64, kotsKinds *kotsutil.KotsKinds, skipPreflights bool) (types.DownstreamVersionStatus, error) { + if util.IsEmbeddedCluster() && baseSequence == nil { + // embedded clusters always require cluster management on initial install + return types.VersionPendingClusterManagement, nil + } + + if kotsKinds.IsConfigurable() && baseSequence == nil { + // initial version should always require configuration (if exists) even if all required items are already set and have values (except for automated installs, which can override this later) + return types.VersionPendingConfig, nil + } + + // only check if the version needs configuration for later versions (not the initial one) since the config is always required for the initial version (except for automated installs, which can override that later) + if baseSequence != nil { + registrySettings, err := s.GetRegistryDetailsForApp(a.ID) + if err != nil { + return types.VersionUnknown, errors.Wrap(err, "failed to get app registry info") + } + needsConfig, err := kotsadmconfig.NeedsConfiguration(a.Slug, sequence, a.IsAirgap, kotsKinds, registrySettings) + if err != nil { + return types.VersionUnknown, errors.Wrap(err, "failed to check if version needs configuration") + } + if needsConfig { + return types.VersionPendingConfig, nil + } + } + + hasStrictPreflights, err := troubleshootpreflight.HasStrictAnalyzers(kotsKinds.Preflight) + if err != nil { + return types.VersionUnknown, errors.Wrap(err, "failed to check strict preflights from spec") + } + if kotsKinds.HasPreflights() && (!skipPreflights || hasStrictPreflights) { + return types.VersionPendingPreflight, nil + } + + return types.VersionPending, nil +} + func (s *KOTSStore) upsertAppDownstreamVersionStatements(appID string, clusterID string, sequence int64, versionLabel string, status types.DownstreamVersionStatus, source string, diffSummary string, diffSummaryError string, commitURL string, gitDeployable bool, preflightsSkipped bool) ([]gorqlite.ParameterizedStatement, error) { statements := []gorqlite.ParameterizedStatement{} @@ -977,24 +920,6 @@ func (s *KOTSStore) UpdateNextAppVersionDiffSummary(appID string, baseSequence i return nil } -func (s *KOTSStore) UpdateAppVersionInstallationSpec(appID string, sequence int64, installation kotsv1beta1.Installation) error { - ser := serializer.NewYAMLSerializer(serializer.DefaultMetaFactory, scheme.Scheme, scheme.Scheme) - var b bytes.Buffer - if err := ser.Encode(&installation, &b); err != nil { - return errors.Wrap(err, "failed to encode installation") - } - - db := persistence.MustGetDBSession() - wr, err := db.WriteOneParameterized(gorqlite.ParameterizedStatement{ - Query: `UPDATE app_version SET kots_installation_spec = ? WHERE app_id = ? AND sequence = ?`, - Arguments: []interface{}{b.String(), appID, sequence}, - }) - if err != nil { - return fmt.Errorf("failed to write: %v: %v", err, wr.Err) - } - return nil -} - func (s *KOTSStore) GetNextAppSequence(appID string) (int64, error) { db := persistence.MustGetDBSession() diff --git a/pkg/store/mock/mock.go b/pkg/store/mock/mock.go index 9deaf4936c..0f65b2fea2 100644 --- a/pkg/store/mock/mock.go +++ b/pkg/store/mock/mock.go @@ -13,10 +13,10 @@ import ( v1beta1 "github.com/replicatedhq/embedded-cluster-kinds/apis/v1beta1" types "github.com/replicatedhq/kots/pkg/airgap/types" types0 "github.com/replicatedhq/kots/pkg/api/downstream/types" - types1 "github.com/replicatedhq/kots/pkg/api/version/types" - types2 "github.com/replicatedhq/kots/pkg/app/types" - types3 "github.com/replicatedhq/kots/pkg/appstate/types" - types4 "github.com/replicatedhq/kots/pkg/gitops/types" + types1 "github.com/replicatedhq/kots/pkg/api/reporting/types" + types2 "github.com/replicatedhq/kots/pkg/api/version/types" + types3 "github.com/replicatedhq/kots/pkg/app/types" + types4 "github.com/replicatedhq/kots/pkg/appstate/types" types5 "github.com/replicatedhq/kots/pkg/kotsadmsnapshot/types" types6 "github.com/replicatedhq/kots/pkg/online/types" types7 "github.com/replicatedhq/kots/pkg/preflight/types" @@ -96,25 +96,11 @@ func (mr *MockStoreMockRecorder) AddDownstreamVersionsDetails(appID, clusterID, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddDownstreamVersionsDetails", reflect.TypeOf((*MockStore)(nil).AddDownstreamVersionsDetails), appID, clusterID, versions, checkIfDeployable) } -// ClearTaskStatus mocks base method. -func (m *MockStore) ClearTaskStatus(taskID string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ClearTaskStatus", taskID) - ret0, _ := ret[0].(error) - return ret0 -} - -// ClearTaskStatus indicates an expected call of ClearTaskStatus. -func (mr *MockStoreMockRecorder) ClearTaskStatus(taskID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearTaskStatus", reflect.TypeOf((*MockStore)(nil).ClearTaskStatus), taskID) -} - // CreateApp mocks base method. -func (m *MockStore) CreateApp(name, upstreamURI, licenseData string, isAirgapEnabled, skipImagePush, registryIsReadOnly bool) (*types2.App, error) { +func (m *MockStore) CreateApp(name, upstreamURI, licenseData string, isAirgapEnabled, skipImagePush, registryIsReadOnly bool) (*types3.App, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateApp", name, upstreamURI, licenseData, isAirgapEnabled, skipImagePush, registryIsReadOnly) - ret0, _ := ret[0].(*types2.App) + ret0, _ := ret[0].(*types3.App) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -126,32 +112,18 @@ func (mr *MockStoreMockRecorder) CreateApp(name, upstreamURI, licenseData, isAir } // CreateAppVersion mocks base method. -func (m *MockStore) CreateAppVersion(appID string, baseSequence *int64, filesInDir, source string, skipPreflights bool, gitops types4.DownstreamGitOps, renderer types9.Renderer) (int64, error) { +func (m *MockStore) CreateAppVersion(appID string, baseSequence *int64, filesInDir, source string, skipPreflights bool, renderer types9.Renderer) (int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateAppVersion", appID, baseSequence, filesInDir, source, skipPreflights, gitops, renderer) + ret := m.ctrl.Call(m, "CreateAppVersion", appID, baseSequence, filesInDir, source, skipPreflights, renderer) ret0, _ := ret[0].(int64) ret1, _ := ret[1].(error) return ret0, ret1 } // CreateAppVersion indicates an expected call of CreateAppVersion. -func (mr *MockStoreMockRecorder) CreateAppVersion(appID, baseSequence, filesInDir, source, skipPreflights, gitops, renderer interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) CreateAppVersion(appID, baseSequence, filesInDir, source, skipPreflights, renderer interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAppVersion", reflect.TypeOf((*MockStore)(nil).CreateAppVersion), appID, baseSequence, filesInDir, source, skipPreflights, gitops, renderer) -} - -// CreateAppVersionArchive mocks base method. -func (m *MockStore) CreateAppVersionArchive(appID string, sequence int64, archivePath string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateAppVersionArchive", appID, sequence, archivePath) - ret0, _ := ret[0].(error) - return ret0 -} - -// CreateAppVersionArchive indicates an expected call of CreateAppVersionArchive. -func (mr *MockStoreMockRecorder) CreateAppVersionArchive(appID, sequence, archivePath interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAppVersionArchive", reflect.TypeOf((*MockStore)(nil).CreateAppVersionArchive), appID, sequence, archivePath) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAppVersion", reflect.TypeOf((*MockStore)(nil).CreateAppVersion), appID, baseSequence, filesInDir, source, skipPreflights, renderer) } // CreateInProgressSupportBundle mocks base method. @@ -429,10 +401,10 @@ func (mr *MockStoreMockRecorder) GetAllAppLicenses() *gomock.Call { } // GetApp mocks base method. -func (m *MockStore) GetApp(appID string) (*types2.App, error) { +func (m *MockStore) GetApp(appID string) (*types3.App, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetApp", appID) - ret0, _ := ret[0].(*types2.App) + ret0, _ := ret[0].(*types3.App) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -444,10 +416,10 @@ func (mr *MockStoreMockRecorder) GetApp(appID interface{}) *gomock.Call { } // GetAppFromSlug mocks base method. -func (m *MockStore) GetAppFromSlug(slug string) (*types2.App, error) { +func (m *MockStore) GetAppFromSlug(slug string) (*types3.App, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAppFromSlug", slug) - ret0, _ := ret[0].(*types2.App) + ret0, _ := ret[0].(*types3.App) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -489,10 +461,10 @@ func (mr *MockStoreMockRecorder) GetAppIDsFromRegistry(hostname interface{}) *go } // GetAppStatus mocks base method. -func (m *MockStore) GetAppStatus(appID string) (*types3.AppStatus, error) { +func (m *MockStore) GetAppStatus(appID string) (*types4.AppStatus, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAppStatus", appID) - ret0, _ := ret[0].(*types3.AppStatus) + ret0, _ := ret[0].(*types4.AppStatus) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -504,10 +476,10 @@ func (mr *MockStoreMockRecorder) GetAppStatus(appID interface{}) *gomock.Call { } // GetAppVersion mocks base method. -func (m *MockStore) GetAppVersion(appID string, sequence int64) (*types1.AppVersion, error) { +func (m *MockStore) GetAppVersion(appID string, sequence int64) (*types2.AppVersion, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAppVersion", appID, sequence) - ret0, _ := ret[0].(*types1.AppVersion) + ret0, _ := ret[0].(*types2.AppVersion) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -788,21 +760,6 @@ func (mr *MockStoreMockRecorder) GetEmbeddedClusterInstallCommandRoles(token int return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedClusterInstallCommandRoles", reflect.TypeOf((*MockStore)(nil).GetEmbeddedClusterInstallCommandRoles), token) } -// GetEmbeddedClusterState mocks base method. -func (m *MockStore) GetEmbeddedClusterState() (string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetEmbeddedClusterState") - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetEmbeddedClusterState indicates an expected call of GetEmbeddedClusterState. -func (mr *MockStoreMockRecorder) GetEmbeddedClusterState() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedClusterState", reflect.TypeOf((*MockStore)(nil).GetEmbeddedClusterState)) -} - // GetIgnoreRBACErrors mocks base method. func (m *MockStore) GetIgnoreRBACErrors(appID string, sequence int64) (bool, error) { m.ctrl.T.Helper() @@ -1195,22 +1152,6 @@ func (mr *MockStoreMockRecorder) GetTargetKotsVersionForVersion(appID, sequence return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTargetKotsVersionForVersion", reflect.TypeOf((*MockStore)(nil).GetTargetKotsVersionForVersion), appID, sequence) } -// GetTaskStatus mocks base method. -func (m *MockStore) GetTaskStatus(taskID string) (string, string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetTaskStatus", taskID) - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(string) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// GetTaskStatus indicates an expected call of GetTaskStatus. -func (mr *MockStoreMockRecorder) GetTaskStatus(taskID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTaskStatus", reflect.TypeOf((*MockStore)(nil).GetTaskStatus), taskID) -} - // HasStrictPreflights mocks base method. func (m *MockStore) HasStrictPreflights(appID string, sequence int64) (bool, error) { m.ctrl.T.Helper() @@ -1346,7 +1287,7 @@ func (mr *MockStoreMockRecorder) IsRollbackSupportedForVersion(appID, sequence i } // IsSnapshotsSupportedForVersion mocks base method. -func (m *MockStore) IsSnapshotsSupportedForVersion(a *types2.App, sequence int64, renderer types9.Renderer) (bool, error) { +func (m *MockStore) IsSnapshotsSupportedForVersion(a *types3.App, sequence int64, renderer types9.Renderer) (bool, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "IsSnapshotsSupportedForVersion", a, sequence, renderer) ret0, _ := ret[0].(bool) @@ -1361,10 +1302,10 @@ func (mr *MockStoreMockRecorder) IsSnapshotsSupportedForVersion(a, sequence, ren } // ListAppsForDownstream mocks base method. -func (m *MockStore) ListAppsForDownstream(clusterID string) ([]*types2.App, error) { +func (m *MockStore) ListAppsForDownstream(clusterID string) ([]*types3.App, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListAppsForDownstream", clusterID) - ret0, _ := ret[0].([]*types2.App) + ret0, _ := ret[0].([]*types3.App) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1406,10 +1347,10 @@ func (mr *MockStoreMockRecorder) ListDownstreamsForApp(appID interface{}) *gomoc } // ListFailedApps mocks base method. -func (m *MockStore) ListFailedApps() ([]*types2.App, error) { +func (m *MockStore) ListFailedApps() ([]*types3.App, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListFailedApps") - ret0, _ := ret[0].([]*types2.App) + ret0, _ := ret[0].([]*types3.App) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1436,10 +1377,10 @@ func (mr *MockStoreMockRecorder) ListInstalledAppSlugs() *gomock.Call { } // ListInstalledApps mocks base method. -func (m *MockStore) ListInstalledApps() ([]*types2.App, error) { +func (m *MockStore) ListInstalledApps() ([]*types3.App, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListInstalledApps") - ret0, _ := ret[0].([]*types2.App) + ret0, _ := ret[0].([]*types3.App) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -1606,7 +1547,7 @@ func (mr *MockStoreMockRecorder) SetAppIsAirgap(appID, isAirgap interface{}) *go } // SetAppStatus mocks base method. -func (m *MockStore) SetAppStatus(appID string, resourceStates types3.ResourceStates, updatedAt time.Time, sequence int64) error { +func (m *MockStore) SetAppStatus(appID string, resourceStates types4.ResourceStates, updatedAt time.Time, sequence int64) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetAppStatus", appID, resourceStates, updatedAt, sequence) ret0, _ := ret[0].(error) @@ -1620,7 +1561,7 @@ func (mr *MockStoreMockRecorder) SetAppStatus(appID, resourceStates, updatedAt, } // SetAutoDeploy mocks base method. -func (m *MockStore) SetAutoDeploy(appID string, autoDeploy types2.AutoDeploy) error { +func (m *MockStore) SetAutoDeploy(appID string, autoDeploy types3.AutoDeploy) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetAutoDeploy", appID, autoDeploy) ret0, _ := ret[0].(error) @@ -1676,20 +1617,6 @@ func (mr *MockStoreMockRecorder) SetEmbeddedClusterInstallCommandRoles(roles int return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetEmbeddedClusterInstallCommandRoles", reflect.TypeOf((*MockStore)(nil).SetEmbeddedClusterInstallCommandRoles), roles) } -// SetEmbeddedClusterState mocks base method. -func (m *MockStore) SetEmbeddedClusterState(state string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetEmbeddedClusterState", state) - ret0, _ := ret[0].(error) - return ret0 -} - -// SetEmbeddedClusterState indicates an expected call of SetEmbeddedClusterState. -func (mr *MockStoreMockRecorder) SetEmbeddedClusterState(state interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetEmbeddedClusterState", reflect.TypeOf((*MockStore)(nil).SetEmbeddedClusterState), state) -} - // SetIgnorePreflightPermissionErrors mocks base method. func (m *MockStore) SetIgnorePreflightPermissionErrors(appID string, sequence int64) error { m.ctrl.T.Helper() @@ -1844,20 +1771,6 @@ func (mr *MockStoreMockRecorder) SetSupportBundleAnalysis(bundleID, insights int return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetSupportBundleAnalysis", reflect.TypeOf((*MockStore)(nil).SetSupportBundleAnalysis), bundleID, insights) } -// SetTaskStatus mocks base method. -func (m *MockStore) SetTaskStatus(taskID, message, status string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetTaskStatus", taskID, message, status) - ret0, _ := ret[0].(error) - return ret0 -} - -// SetTaskStatus indicates an expected call of SetTaskStatus. -func (mr *MockStoreMockRecorder) SetTaskStatus(taskID, message, status interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTaskStatus", reflect.TypeOf((*MockStore)(nil).SetTaskStatus), taskID, message, status) -} - // SetUpdateCheckerSpec mocks base method. func (m *MockStore) SetUpdateCheckerSpec(appID, updateCheckerSpec string) error { m.ctrl.T.Helper() @@ -1873,18 +1786,18 @@ func (mr *MockStoreMockRecorder) SetUpdateCheckerSpec(appID, updateCheckerSpec i } // UpdateAppLicense mocks base method. -func (m *MockStore) UpdateAppLicense(appID string, sequence int64, archiveDir string, newLicense *v1beta10.License, originalLicenseData string, channelChanged, failOnVersionCreate bool, gitops types4.DownstreamGitOps, renderer types9.Renderer) (int64, error) { +func (m *MockStore) UpdateAppLicense(appID string, sequence int64, archiveDir string, newLicense *v1beta10.License, originalLicenseData string, channelChanged, failOnVersionCreate bool, renderer types9.Renderer, reportingInfo *types1.ReportingInfo) (int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateAppLicense", appID, sequence, archiveDir, newLicense, originalLicenseData, channelChanged, failOnVersionCreate, gitops, renderer) + ret := m.ctrl.Call(m, "UpdateAppLicense", appID, sequence, archiveDir, newLicense, originalLicenseData, channelChanged, failOnVersionCreate, renderer, reportingInfo) ret0, _ := ret[0].(int64) ret1, _ := ret[1].(error) return ret0, ret1 } // UpdateAppLicense indicates an expected call of UpdateAppLicense. -func (mr *MockStoreMockRecorder) UpdateAppLicense(appID, sequence, archiveDir, newLicense, originalLicenseData, channelChanged, failOnVersionCreate, gitops, renderer interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) UpdateAppLicense(appID, sequence, archiveDir, newLicense, originalLicenseData, channelChanged, failOnVersionCreate, renderer, reportingInfo interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAppLicense", reflect.TypeOf((*MockStore)(nil).UpdateAppLicense), appID, sequence, archiveDir, newLicense, originalLicenseData, channelChanged, failOnVersionCreate, gitops, renderer) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAppLicense", reflect.TypeOf((*MockStore)(nil).UpdateAppLicense), appID, sequence, archiveDir, newLicense, originalLicenseData, channelChanged, failOnVersionCreate, renderer, reportingInfo) } // UpdateAppLicenseSyncNow mocks base method. @@ -1902,31 +1815,17 @@ func (mr *MockStoreMockRecorder) UpdateAppLicenseSyncNow(appID interface{}) *gom } // UpdateAppVersion mocks base method. -func (m *MockStore) UpdateAppVersion(appID string, sequence int64, baseSequence *int64, filesInDir, source string, skipPreflights bool, gitops types4.DownstreamGitOps, renderer types9.Renderer) error { +func (m *MockStore) UpdateAppVersion(appID string, sequence int64, baseSequence *int64, filesInDir, source string, skipPreflights bool, renderer types9.Renderer) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateAppVersion", appID, sequence, baseSequence, filesInDir, source, skipPreflights, gitops, renderer) + ret := m.ctrl.Call(m, "UpdateAppVersion", appID, sequence, baseSequence, filesInDir, source, skipPreflights, renderer) ret0, _ := ret[0].(error) return ret0 } // UpdateAppVersion indicates an expected call of UpdateAppVersion. -func (mr *MockStoreMockRecorder) UpdateAppVersion(appID, sequence, baseSequence, filesInDir, source, skipPreflights, gitops, renderer interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAppVersion", reflect.TypeOf((*MockStore)(nil).UpdateAppVersion), appID, sequence, baseSequence, filesInDir, source, skipPreflights, gitops, renderer) -} - -// UpdateAppVersionInstallationSpec mocks base method. -func (m *MockStore) UpdateAppVersionInstallationSpec(appID string, sequence int64, spec v1beta10.Installation) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateAppVersionInstallationSpec", appID, sequence, spec) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpdateAppVersionInstallationSpec indicates an expected call of UpdateAppVersionInstallationSpec. -func (mr *MockStoreMockRecorder) UpdateAppVersionInstallationSpec(appID, sequence, spec interface{}) *gomock.Call { +func (mr *MockStoreMockRecorder) UpdateAppVersion(appID, sequence, baseSequence, filesInDir, source, skipPreflights, renderer interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAppVersionInstallationSpec", reflect.TypeOf((*MockStore)(nil).UpdateAppVersionInstallationSpec), appID, sequence, spec) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAppVersion", reflect.TypeOf((*MockStore)(nil).UpdateAppVersion), appID, sequence, baseSequence, filesInDir, source, skipPreflights, renderer) } // UpdateDownstreamDeployStatus mocks base method. @@ -2027,20 +1926,6 @@ func (mr *MockStoreMockRecorder) UpdateSupportBundle(bundle interface{}) *gomock return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateSupportBundle", reflect.TypeOf((*MockStore)(nil).UpdateSupportBundle), bundle) } -// UpdateTaskStatusTimestamp mocks base method. -func (m *MockStore) UpdateTaskStatusTimestamp(taskID string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateTaskStatusTimestamp", taskID) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpdateTaskStatusTimestamp indicates an expected call of UpdateTaskStatusTimestamp. -func (mr *MockStoreMockRecorder) UpdateTaskStatusTimestamp(taskID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTaskStatusTimestamp", reflect.TypeOf((*MockStore)(nil).UpdateTaskStatusTimestamp), taskID) -} - // UploadSupportBundle mocks base method. func (m *MockStore) UploadSupportBundle(bundleID, archivePath string, marshalledTree []byte) error { m.ctrl.T.Helper() @@ -2610,87 +2495,6 @@ func (mr *MockAirgapStoreMockRecorder) SetAppIsAirgap(appID, isAirgap interface{ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetAppIsAirgap", reflect.TypeOf((*MockAirgapStore)(nil).SetAppIsAirgap), appID, isAirgap) } -// MockTaskStore is a mock of TaskStore interface. -type MockTaskStore struct { - ctrl *gomock.Controller - recorder *MockTaskStoreMockRecorder -} - -// MockTaskStoreMockRecorder is the mock recorder for MockTaskStore. -type MockTaskStoreMockRecorder struct { - mock *MockTaskStore -} - -// NewMockTaskStore creates a new mock instance. -func NewMockTaskStore(ctrl *gomock.Controller) *MockTaskStore { - mock := &MockTaskStore{ctrl: ctrl} - mock.recorder = &MockTaskStoreMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockTaskStore) EXPECT() *MockTaskStoreMockRecorder { - return m.recorder -} - -// ClearTaskStatus mocks base method. -func (m *MockTaskStore) ClearTaskStatus(taskID string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ClearTaskStatus", taskID) - ret0, _ := ret[0].(error) - return ret0 -} - -// ClearTaskStatus indicates an expected call of ClearTaskStatus. -func (mr *MockTaskStoreMockRecorder) ClearTaskStatus(taskID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClearTaskStatus", reflect.TypeOf((*MockTaskStore)(nil).ClearTaskStatus), taskID) -} - -// GetTaskStatus mocks base method. -func (m *MockTaskStore) GetTaskStatus(taskID string) (string, string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetTaskStatus", taskID) - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(string) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// GetTaskStatus indicates an expected call of GetTaskStatus. -func (mr *MockTaskStoreMockRecorder) GetTaskStatus(taskID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTaskStatus", reflect.TypeOf((*MockTaskStore)(nil).GetTaskStatus), taskID) -} - -// SetTaskStatus mocks base method. -func (m *MockTaskStore) SetTaskStatus(taskID, message, status string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetTaskStatus", taskID, message, status) - ret0, _ := ret[0].(error) - return ret0 -} - -// SetTaskStatus indicates an expected call of SetTaskStatus. -func (mr *MockTaskStoreMockRecorder) SetTaskStatus(taskID, message, status interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTaskStatus", reflect.TypeOf((*MockTaskStore)(nil).SetTaskStatus), taskID, message, status) -} - -// UpdateTaskStatusTimestamp mocks base method. -func (m *MockTaskStore) UpdateTaskStatusTimestamp(taskID string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateTaskStatusTimestamp", taskID) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpdateTaskStatusTimestamp indicates an expected call of UpdateTaskStatusTimestamp. -func (mr *MockTaskStoreMockRecorder) UpdateTaskStatusTimestamp(taskID interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTaskStatusTimestamp", reflect.TypeOf((*MockTaskStore)(nil).UpdateTaskStatusTimestamp), taskID) -} - // MockSessionStore is a mock of SessionStore interface. type MockSessionStore struct { ctrl *gomock.Controller @@ -2810,10 +2614,10 @@ func (m *MockAppStatusStore) EXPECT() *MockAppStatusStoreMockRecorder { } // GetAppStatus mocks base method. -func (m *MockAppStatusStore) GetAppStatus(appID string) (*types3.AppStatus, error) { +func (m *MockAppStatusStore) GetAppStatus(appID string) (*types4.AppStatus, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAppStatus", appID) - ret0, _ := ret[0].(*types3.AppStatus) + ret0, _ := ret[0].(*types4.AppStatus) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2825,7 +2629,7 @@ func (mr *MockAppStatusStoreMockRecorder) GetAppStatus(appID interface{}) *gomoc } // SetAppStatus mocks base method. -func (m *MockAppStatusStore) SetAppStatus(appID string, resourceStates types3.ResourceStates, updatedAt time.Time, sequence int64) error { +func (m *MockAppStatusStore) SetAppStatus(appID string, resourceStates types4.ResourceStates, updatedAt time.Time, sequence int64) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetAppStatus", appID, resourceStates, updatedAt, sequence) ret0, _ := ret[0].(error) @@ -2876,10 +2680,10 @@ func (mr *MockAppStoreMockRecorder) AddAppToAllDownstreams(appID interface{}) *g } // CreateApp mocks base method. -func (m *MockAppStore) CreateApp(name, upstreamURI, licenseData string, isAirgapEnabled, skipImagePush, registryIsReadOnly bool) (*types2.App, error) { +func (m *MockAppStore) CreateApp(name, upstreamURI, licenseData string, isAirgapEnabled, skipImagePush, registryIsReadOnly bool) (*types3.App, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CreateApp", name, upstreamURI, licenseData, isAirgapEnabled, skipImagePush, registryIsReadOnly) - ret0, _ := ret[0].(*types2.App) + ret0, _ := ret[0].(*types3.App) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2891,10 +2695,10 @@ func (mr *MockAppStoreMockRecorder) CreateApp(name, upstreamURI, licenseData, is } // GetApp mocks base method. -func (m *MockAppStore) GetApp(appID string) (*types2.App, error) { +func (m *MockAppStore) GetApp(appID string) (*types3.App, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetApp", appID) - ret0, _ := ret[0].(*types2.App) + ret0, _ := ret[0].(*types3.App) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2906,10 +2710,10 @@ func (mr *MockAppStoreMockRecorder) GetApp(appID interface{}) *gomock.Call { } // GetAppFromSlug mocks base method. -func (m *MockAppStore) GetAppFromSlug(slug string) (*types2.App, error) { +func (m *MockAppStore) GetAppFromSlug(slug string) (*types3.App, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAppFromSlug", slug) - ret0, _ := ret[0].(*types2.App) + ret0, _ := ret[0].(*types3.App) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2966,10 +2770,10 @@ func (mr *MockAppStoreMockRecorder) IsGitOpsEnabledForApp(appID interface{}) *go } // ListAppsForDownstream mocks base method. -func (m *MockAppStore) ListAppsForDownstream(clusterID string) ([]*types2.App, error) { +func (m *MockAppStore) ListAppsForDownstream(clusterID string) ([]*types3.App, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListAppsForDownstream", clusterID) - ret0, _ := ret[0].([]*types2.App) + ret0, _ := ret[0].([]*types3.App) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -2996,10 +2800,10 @@ func (mr *MockAppStoreMockRecorder) ListDownstreamsForApp(appID interface{}) *go } // ListFailedApps mocks base method. -func (m *MockAppStore) ListFailedApps() ([]*types2.App, error) { +func (m *MockAppStore) ListFailedApps() ([]*types3.App, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListFailedApps") - ret0, _ := ret[0].([]*types2.App) + ret0, _ := ret[0].([]*types3.App) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -3026,10 +2830,10 @@ func (mr *MockAppStoreMockRecorder) ListInstalledAppSlugs() *gomock.Call { } // ListInstalledApps mocks base method. -func (m *MockAppStore) ListInstalledApps() ([]*types2.App, error) { +func (m *MockAppStore) ListInstalledApps() ([]*types3.App, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ListInstalledApps") - ret0, _ := ret[0].([]*types2.App) + ret0, _ := ret[0].([]*types3.App) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -3083,7 +2887,7 @@ func (mr *MockAppStoreMockRecorder) SetAppInstallState(appID, state interface{}) } // SetAutoDeploy mocks base method. -func (m *MockAppStore) SetAutoDeploy(appID string, autoDeploy types2.AutoDeploy) error { +func (m *MockAppStore) SetAutoDeploy(appID string, autoDeploy types3.AutoDeploy) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetAutoDeploy", appID, autoDeploy) ret0, _ := ret[0].(error) @@ -3649,32 +3453,18 @@ func (m *MockVersionStore) EXPECT() *MockVersionStoreMockRecorder { } // CreateAppVersion mocks base method. -func (m *MockVersionStore) CreateAppVersion(appID string, baseSequence *int64, filesInDir, source string, skipPreflights bool, gitops types4.DownstreamGitOps, renderer types9.Renderer) (int64, error) { +func (m *MockVersionStore) CreateAppVersion(appID string, baseSequence *int64, filesInDir, source string, skipPreflights bool, renderer types9.Renderer) (int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateAppVersion", appID, baseSequence, filesInDir, source, skipPreflights, gitops, renderer) + ret := m.ctrl.Call(m, "CreateAppVersion", appID, baseSequence, filesInDir, source, skipPreflights, renderer) ret0, _ := ret[0].(int64) ret1, _ := ret[1].(error) return ret0, ret1 } // CreateAppVersion indicates an expected call of CreateAppVersion. -func (mr *MockVersionStoreMockRecorder) CreateAppVersion(appID, baseSequence, filesInDir, source, skipPreflights, gitops, renderer interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAppVersion", reflect.TypeOf((*MockVersionStore)(nil).CreateAppVersion), appID, baseSequence, filesInDir, source, skipPreflights, gitops, renderer) -} - -// CreateAppVersionArchive mocks base method. -func (m *MockVersionStore) CreateAppVersionArchive(appID string, sequence int64, archivePath string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "CreateAppVersionArchive", appID, sequence, archivePath) - ret0, _ := ret[0].(error) - return ret0 -} - -// CreateAppVersionArchive indicates an expected call of CreateAppVersionArchive. -func (mr *MockVersionStoreMockRecorder) CreateAppVersionArchive(appID, sequence, archivePath interface{}) *gomock.Call { +func (mr *MockVersionStoreMockRecorder) CreateAppVersion(appID, baseSequence, filesInDir, source, skipPreflights, renderer interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAppVersionArchive", reflect.TypeOf((*MockVersionStore)(nil).CreateAppVersionArchive), appID, sequence, archivePath) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateAppVersion", reflect.TypeOf((*MockVersionStore)(nil).CreateAppVersion), appID, baseSequence, filesInDir, source, skipPreflights, renderer) } // CreatePendingDownloadAppVersion mocks base method. @@ -3693,10 +3483,10 @@ func (mr *MockVersionStoreMockRecorder) CreatePendingDownloadAppVersion(appID, u } // GetAppVersion mocks base method. -func (m *MockVersionStore) GetAppVersion(appID string, sequence int64) (*types1.AppVersion, error) { +func (m *MockVersionStore) GetAppVersion(appID string, sequence int64) (*types2.AppVersion, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetAppVersion", appID, sequence) - ret0, _ := ret[0].(*types1.AppVersion) + ret0, _ := ret[0].(*types2.AppVersion) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -3873,7 +3663,7 @@ func (mr *MockVersionStoreMockRecorder) IsRollbackSupportedForVersion(appID, seq } // IsSnapshotsSupportedForVersion mocks base method. -func (m *MockVersionStore) IsSnapshotsSupportedForVersion(a *types2.App, sequence int64, renderer types9.Renderer) (bool, error) { +func (m *MockVersionStore) IsSnapshotsSupportedForVersion(a *types3.App, sequence int64, renderer types9.Renderer) (bool, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "IsSnapshotsSupportedForVersion", a, sequence, renderer) ret0, _ := ret[0].(bool) @@ -3888,31 +3678,17 @@ func (mr *MockVersionStoreMockRecorder) IsSnapshotsSupportedForVersion(a, sequen } // UpdateAppVersion mocks base method. -func (m *MockVersionStore) UpdateAppVersion(appID string, sequence int64, baseSequence *int64, filesInDir, source string, skipPreflights bool, gitops types4.DownstreamGitOps, renderer types9.Renderer) error { +func (m *MockVersionStore) UpdateAppVersion(appID string, sequence int64, baseSequence *int64, filesInDir, source string, skipPreflights bool, renderer types9.Renderer) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateAppVersion", appID, sequence, baseSequence, filesInDir, source, skipPreflights, gitops, renderer) + ret := m.ctrl.Call(m, "UpdateAppVersion", appID, sequence, baseSequence, filesInDir, source, skipPreflights, renderer) ret0, _ := ret[0].(error) return ret0 } // UpdateAppVersion indicates an expected call of UpdateAppVersion. -func (mr *MockVersionStoreMockRecorder) UpdateAppVersion(appID, sequence, baseSequence, filesInDir, source, skipPreflights, gitops, renderer interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAppVersion", reflect.TypeOf((*MockVersionStore)(nil).UpdateAppVersion), appID, sequence, baseSequence, filesInDir, source, skipPreflights, gitops, renderer) -} - -// UpdateAppVersionInstallationSpec mocks base method. -func (m *MockVersionStore) UpdateAppVersionInstallationSpec(appID string, sequence int64, spec v1beta10.Installation) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateAppVersionInstallationSpec", appID, sequence, spec) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpdateAppVersionInstallationSpec indicates an expected call of UpdateAppVersionInstallationSpec. -func (mr *MockVersionStoreMockRecorder) UpdateAppVersionInstallationSpec(appID, sequence, spec interface{}) *gomock.Call { +func (mr *MockVersionStoreMockRecorder) UpdateAppVersion(appID, sequence, baseSequence, filesInDir, source, skipPreflights, renderer interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAppVersionInstallationSpec", reflect.TypeOf((*MockVersionStore)(nil).UpdateAppVersionInstallationSpec), appID, sequence, spec) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAppVersion", reflect.TypeOf((*MockVersionStore)(nil).UpdateAppVersion), appID, sequence, baseSequence, filesInDir, source, skipPreflights, renderer) } // UpdateNextAppVersionDiffSummary mocks base method. @@ -3998,18 +3774,18 @@ func (mr *MockLicenseStoreMockRecorder) GetLicenseForAppVersion(appID, sequence } // UpdateAppLicense mocks base method. -func (m *MockLicenseStore) UpdateAppLicense(appID string, sequence int64, archiveDir string, newLicense *v1beta10.License, originalLicenseData string, channelChanged, failOnVersionCreate bool, gitops types4.DownstreamGitOps, renderer types9.Renderer) (int64, error) { +func (m *MockLicenseStore) UpdateAppLicense(appID string, sequence int64, archiveDir string, newLicense *v1beta10.License, originalLicenseData string, channelChanged, failOnVersionCreate bool, renderer types9.Renderer, reportingInfo *types1.ReportingInfo) (int64, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateAppLicense", appID, sequence, archiveDir, newLicense, originalLicenseData, channelChanged, failOnVersionCreate, gitops, renderer) + ret := m.ctrl.Call(m, "UpdateAppLicense", appID, sequence, archiveDir, newLicense, originalLicenseData, channelChanged, failOnVersionCreate, renderer, reportingInfo) ret0, _ := ret[0].(int64) ret1, _ := ret[1].(error) return ret0, ret1 } // UpdateAppLicense indicates an expected call of UpdateAppLicense. -func (mr *MockLicenseStoreMockRecorder) UpdateAppLicense(appID, sequence, archiveDir, newLicense, originalLicenseData, channelChanged, failOnVersionCreate, gitops, renderer interface{}) *gomock.Call { +func (mr *MockLicenseStoreMockRecorder) UpdateAppLicense(appID, sequence, archiveDir, newLicense, originalLicenseData, channelChanged, failOnVersionCreate, renderer, reportingInfo interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAppLicense", reflect.TypeOf((*MockLicenseStore)(nil).UpdateAppLicense), appID, sequence, archiveDir, newLicense, originalLicenseData, channelChanged, failOnVersionCreate, gitops, renderer) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateAppLicense", reflect.TypeOf((*MockLicenseStore)(nil).UpdateAppLicense), appID, sequence, archiveDir, newLicense, originalLicenseData, channelChanged, failOnVersionCreate, renderer, reportingInfo) } // UpdateAppLicenseSyncNow mocks base method. @@ -4346,21 +4122,6 @@ func (mr *MockEmbeddedStoreMockRecorder) GetEmbeddedClusterAuthToken() *gomock.C return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedClusterAuthToken", reflect.TypeOf((*MockEmbeddedStore)(nil).GetEmbeddedClusterAuthToken)) } -// GetEmbeddedClusterState mocks base method. -func (m *MockEmbeddedStore) GetEmbeddedClusterState() (string, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetEmbeddedClusterState") - ret0, _ := ret[0].(string) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// GetEmbeddedClusterState indicates an expected call of GetEmbeddedClusterState. -func (mr *MockEmbeddedStoreMockRecorder) GetEmbeddedClusterState() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetEmbeddedClusterState", reflect.TypeOf((*MockEmbeddedStore)(nil).GetEmbeddedClusterState)) -} - // SetEmbeddedClusterAuthToken mocks base method. func (m *MockEmbeddedStore) SetEmbeddedClusterAuthToken(token string) error { m.ctrl.T.Helper() @@ -4375,20 +4136,6 @@ func (mr *MockEmbeddedStoreMockRecorder) SetEmbeddedClusterAuthToken(token inter return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetEmbeddedClusterAuthToken", reflect.TypeOf((*MockEmbeddedStore)(nil).SetEmbeddedClusterAuthToken), token) } -// SetEmbeddedClusterState mocks base method. -func (m *MockEmbeddedStore) SetEmbeddedClusterState(state string) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetEmbeddedClusterState", state) - ret0, _ := ret[0].(error) - return ret0 -} - -// SetEmbeddedClusterState indicates an expected call of SetEmbeddedClusterState. -func (mr *MockEmbeddedStoreMockRecorder) SetEmbeddedClusterState(state interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetEmbeddedClusterState", reflect.TypeOf((*MockEmbeddedStore)(nil).SetEmbeddedClusterState), state) -} - // MockBrandingStore is a mock of BrandingStore interface. type MockBrandingStore struct { ctrl *gomock.Controller diff --git a/pkg/store/store.go b/pkg/store/store.go index 409eb32e97..a5d9ecc28d 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -2,6 +2,7 @@ package store import ( "github.com/replicatedhq/kots/pkg/store/kotsstore" + "github.com/replicatedhq/kots/pkg/util" ) var ( @@ -12,11 +13,13 @@ var ( var _ Store = (*kotsstore.KOTSStore)(nil) func GetStore() Store { + if util.IsUpgradeService() { + panic("store cannot not be used in the upgrade service") + } if !hasStore { globalStore = storeFromEnv() hasStore = true } - return globalStore } diff --git a/pkg/store/store_interface.go b/pkg/store/store_interface.go index 05f1ee395e..049fc2de18 100644 --- a/pkg/store/store_interface.go +++ b/pkg/store/store_interface.go @@ -7,10 +7,10 @@ import ( embeddedclusterv1beta1 "github.com/replicatedhq/embedded-cluster-kinds/apis/v1beta1" airgaptypes "github.com/replicatedhq/kots/pkg/airgap/types" downstreamtypes "github.com/replicatedhq/kots/pkg/api/downstream/types" + reportingtypes "github.com/replicatedhq/kots/pkg/api/reporting/types" versiontypes "github.com/replicatedhq/kots/pkg/api/version/types" apptypes "github.com/replicatedhq/kots/pkg/app/types" appstatetypes "github.com/replicatedhq/kots/pkg/appstate/types" - gitopstypes "github.com/replicatedhq/kots/pkg/gitops/types" snapshottypes "github.com/replicatedhq/kots/pkg/kotsadmsnapshot/types" installationtypes "github.com/replicatedhq/kots/pkg/online/types" preflighttypes "github.com/replicatedhq/kots/pkg/preflight/types" @@ -32,7 +32,6 @@ type Store interface { PreflightStore PrometheusStore AirgapStore - TaskStore SessionStore AppStatusStore AppStore @@ -99,13 +98,6 @@ type AirgapStore interface { SetAppIsAirgap(appID string, isAirgap bool) error } -type TaskStore interface { - SetTaskStatus(taskID string, message string, status string) error - UpdateTaskStatusTimestamp(taskID string) error - ClearTaskStatus(taskID string) error - GetTaskStatus(taskID string) (status string, message string, err error) -} - type SessionStore interface { CreateSession(user *usertypes.User, issuedAt time.Time, expiresAt time.Time, roles []string) (*sessiontypes.Session, error) DeleteSession(sessionID string) error @@ -186,17 +178,15 @@ type VersionStore interface { IsRollbackSupportedForVersion(appID string, sequence int64) (bool, error) IsSnapshotsSupportedForVersion(a *apptypes.App, sequence int64, renderer rendertypes.Renderer) (bool, error) GetTargetKotsVersionForVersion(appID string, sequence int64) (string, error) - CreateAppVersionArchive(appID string, sequence int64, archivePath string) error GetAppVersionArchive(appID string, sequence int64, dstPath string) error GetAppVersionBaseSequence(appID string, versionLabel string) (int64, error) GetAppVersionBaseArchive(appID string, versionLabel string) (string, int64, error) CreatePendingDownloadAppVersion(appID string, update upstreamtypes.Update, kotsApplication *kotsv1beta1.Application, license *kotsv1beta1.License) (int64, error) - UpdateAppVersion(appID string, sequence int64, baseSequence *int64, filesInDir string, source string, skipPreflights bool, gitops gitopstypes.DownstreamGitOps, renderer rendertypes.Renderer) error - CreateAppVersion(appID string, baseSequence *int64, filesInDir string, source string, skipPreflights bool, gitops gitopstypes.DownstreamGitOps, renderer rendertypes.Renderer) (int64, error) + UpdateAppVersion(appID string, sequence int64, baseSequence *int64, filesInDir string, source string, skipPreflights bool, renderer rendertypes.Renderer) error + CreateAppVersion(appID string, baseSequence *int64, filesInDir string, source string, skipPreflights bool, renderer rendertypes.Renderer) (int64, error) GetAppVersion(appID string, sequence int64) (*versiontypes.AppVersion, error) GetLatestAppSequence(appID string, downloadedOnly bool) (int64, error) UpdateNextAppVersionDiffSummary(appID string, baseSequence int64) error - UpdateAppVersionInstallationSpec(appID string, sequence int64, spec kotsv1beta1.Installation) error GetNextAppSequence(appID string) (int64, error) GetCurrentUpdateCursor(appID string, channelID string) (string, error) HasStrictPreflights(appID string, sequence int64) (bool, error) @@ -209,7 +199,7 @@ type LicenseStore interface { GetAllAppLicenses() ([]*kotsv1beta1.License, error) // originalLicenseData is the data received from the replicated API that was never marshalled locally so all fields are intact - UpdateAppLicense(appID string, sequence int64, archiveDir string, newLicense *kotsv1beta1.License, originalLicenseData string, channelChanged bool, failOnVersionCreate bool, gitops gitopstypes.DownstreamGitOps, renderer rendertypes.Renderer) (int64, error) + UpdateAppLicense(appID string, sequence int64, archiveDir string, newLicense *kotsv1beta1.License, originalLicenseData string, channelChanged bool, failOnVersionCreate bool, renderer rendertypes.Renderer, reportingInfo *reportingtypes.ReportingInfo) (int64, error) UpdateAppLicenseSyncNow(appID string) error } @@ -241,8 +231,6 @@ type KotsadmParamsStore interface { type EmbeddedStore interface { GetEmbeddedClusterAuthToken() (string, error) SetEmbeddedClusterAuthToken(token string) error - SetEmbeddedClusterState(state string) error - GetEmbeddedClusterState() (string, error) } type BrandingStore interface { diff --git a/pkg/tasks/tasks.go b/pkg/tasks/tasks.go index c342f6716e..be897e93a4 100644 --- a/pkg/tasks/tasks.go +++ b/pkg/tasks/tasks.go @@ -1,37 +1,45 @@ package tasks import ( + "fmt" "time" "github.com/pkg/errors" "github.com/replicatedhq/kots/pkg/logger" - "github.com/replicatedhq/kots/pkg/store" + "github.com/replicatedhq/kots/pkg/persistence" "github.com/replicatedhq/kots/pkg/util" + "github.com/rqlite/gorqlite" ) -func StartUpdateTaskMonitor(taskID string, finishedChan <-chan error) { +type TaskStatus struct { + Message string `json:"message"` + Status string `json:"status"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func StartTaskMonitor(taskID string, finishedChan <-chan error) { go func() { var finalError error defer func() { if finalError == nil { - if err := store.GetStore().ClearTaskStatus(taskID); err != nil { - logger.Error(errors.Wrap(err, "failed to clear update-download task status")) + if err := ClearTaskStatus(taskID); err != nil { + logger.Error(errors.Wrapf(err, "failed to clear %s task status", taskID)) } } else { errMsg := finalError.Error() if cause, ok := errors.Cause(finalError).(util.ActionableError); ok { errMsg = cause.Error() } - if err := store.GetStore().SetTaskStatus(taskID, errMsg, "failed"); err != nil { - logger.Error(errors.Wrap(err, "failed to set error on update-download task status")) + if err := SetTaskStatus(taskID, errMsg, "failed"); err != nil { + logger.Error(errors.Wrapf(err, "failed to set error on %s task status", taskID)) } } }() for { select { - case <-time.After(time.Second): - if err := store.GetStore().UpdateTaskStatusTimestamp(taskID); err != nil { + case <-time.After(time.Second * 2): + if err := UpdateTaskStatusTimestamp(taskID); err != nil { logger.Error(err) } case err := <-finishedChan: @@ -41,3 +49,95 @@ func StartUpdateTaskMonitor(taskID string, finishedChan <-chan error) { } }() } + +func StartTicker(taskID string, finishedChan <-chan struct{}) { + go func() { + for { + select { + case <-time.After(time.Second * 2): + if err := UpdateTaskStatusTimestamp(taskID); err != nil { + logger.Error(err) + } + case <-finishedChan: + return + } + } + }() +} + +func SetTaskStatus(id string, message string, status string) error { + db := persistence.MustGetDBSession() + + query := ` +INSERT INTO api_task_status (id, updated_at, current_message, status) +VALUES (?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET + updated_at = EXCLUDED.updated_at, + current_message = EXCLUDED.current_message, + status = EXCLUDED.status +` + wr, err := db.WriteOneParameterized(gorqlite.ParameterizedStatement{ + Query: query, + Arguments: []interface{}{id, time.Now().Unix(), message, status}, + }) + if err != nil { + return fmt.Errorf("failed to write: %v: %v", err, wr.Err) + } + + return nil +} + +func UpdateTaskStatusTimestamp(id string) error { + db := persistence.MustGetDBSession() + + query := `UPDATE api_task_status SET updated_at = ? WHERE id = ?` + wr, err := db.WriteOneParameterized(gorqlite.ParameterizedStatement{ + Query: query, + Arguments: []interface{}{time.Now().Unix(), id}, + }) + if err != nil { + return fmt.Errorf("failed to write: %v: %v", err, wr.Err) + } + + return nil +} + +func ClearTaskStatus(id string) error { + db := persistence.MustGetDBSession() + + query := `DELETE FROM api_task_status WHERE id = ?` + wr, err := db.WriteOneParameterized(gorqlite.ParameterizedStatement{ + Query: query, + Arguments: []interface{}{id}, + }) + if err != nil { + return fmt.Errorf("failed to write: %v: %v", err, wr.Err) + } + + return nil +} + +func GetTaskStatus(id string) (string, string, error) { + db := persistence.MustGetDBSession() + + // only return the status if it was updated in the last minute + query := `SELECT status, current_message from api_task_status WHERE id = ? AND strftime('%s', 'now') - updated_at < 60` + rows, err := db.QueryOneParameterized(gorqlite.ParameterizedStatement{ + Query: query, + Arguments: []interface{}{id}, + }) + if err != nil { + return "", "", fmt.Errorf("failed to query app: %v: %v", err, rows.Err) + } + if !rows.Next() { + return "", "", nil + } + + var status gorqlite.NullString + var message gorqlite.NullString + if err := rows.Scan(&status, &message); err != nil { + return "", "", errors.Wrap(err, "failed to scan") + } + + return status.String, message.String, nil +} diff --git a/pkg/tests/renderdir/renderdir_test.go b/pkg/tests/renderdir/renderdir_test.go index 9e95b4a37f..3eb9d5ccb1 100644 --- a/pkg/tests/renderdir/renderdir_test.go +++ b/pkg/tests/renderdir/renderdir_test.go @@ -13,8 +13,6 @@ import ( cp "github.com/otiai10/copy" "github.com/replicatedhq/kots/pkg/render" rendertypes "github.com/replicatedhq/kots/pkg/render/types" - "github.com/replicatedhq/kots/pkg/store" - mock_store "github.com/replicatedhq/kots/pkg/store/mock" "github.com/replicatedhq/kots/pkg/util" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" "github.com/stretchr/testify/assert" @@ -36,12 +34,6 @@ func TestKotsRenderDir(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() - mockStore := mock_store.NewMockStore(ctrl) - store.SetStore(mockStore) - defer store.SetStore(nil) - - mockStore.EXPECT().UpdateAppVersionInstallationSpec(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - t.Setenv("USE_MOCK_REPORTING", "1") defer os.Unsetenv("USE_MOCK_REPORTING") diff --git a/pkg/update/required.go b/pkg/update/required.go new file mode 100644 index 0000000000..3ed40dca22 --- /dev/null +++ b/pkg/update/required.go @@ -0,0 +1,116 @@ +package update + +import ( + "fmt" + "strings" + + "github.com/blang/semver" + "github.com/pkg/errors" + downstreamtypes "github.com/replicatedhq/kots/pkg/api/downstream/types" + apptypes "github.com/replicatedhq/kots/pkg/app/types" + "github.com/replicatedhq/kots/pkg/cursor" + "github.com/replicatedhq/kots/pkg/kotsutil" + "github.com/replicatedhq/kots/pkg/store" + upstreamtypes "github.com/replicatedhq/kots/pkg/upstream/types" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" +) + +func isUpdateDeployable(updateCursor string, updates []upstreamtypes.Update) (bool, string) { + // iterate over updates in reverse since they are sorted in descending order + requiredUpdates := []string{} + for i := len(updates) - 1; i >= 0; i-- { + if updates[i].Cursor == updateCursor { + break + } + if updates[i].IsRequired { + requiredUpdates = append(requiredUpdates, updates[i].VersionLabel) + } + } + if len(requiredUpdates) > 0 { + return false, getNonDeployableCause(requiredUpdates) + } + return true, "" +} + +func IsAirgapUpdateDeployable(app *apptypes.App, airgap *kotsv1beta1.Airgap) (bool, string, error) { + appVersions, err := store.GetStore().FindDownstreamVersions(app.ID, true) + if err != nil { + return false, "", errors.Wrap(err, "failed to get downstream versions") + } + license, err := kotsutil.LoadLicenseFromBytes([]byte(app.License)) + if err != nil { + return false, "", errors.Wrap(err, "failed to load license") + } + requiredUpdates, err := getRequiredAirgapUpdates(airgap, license, appVersions.AllVersions, app.ChannelChanged) + if err != nil { + return false, "", errors.Wrap(err, "failed to get missing required versions") + } + if len(requiredUpdates) > 0 { + return false, getNonDeployableCause(requiredUpdates), nil + } + return true, "", nil +} + +func getRequiredAirgapUpdates(airgap *kotsv1beta1.Airgap, license *kotsv1beta1.License, installedVersions []*downstreamtypes.DownstreamVersion, channelChanged bool) ([]string, error) { + requiredUpdates := make([]string, 0) + // If no versions are installed, we can consider this an initial install. + // If the channel changed, we can consider this an initial install. + if len(installedVersions) == 0 || channelChanged { + return requiredUpdates, nil + } + + for _, requiredRelease := range airgap.Spec.RequiredReleases { + laterReleaseInstalled := false + for _, appVersion := range installedVersions { + requiredSemver, requiredSemverErr := semver.ParseTolerant(requiredRelease.VersionLabel) + + // semvers can be compared across channels + // if a semmver is missing, fallback to comparing the cursor but only if channel is the same + if license.Spec.IsSemverRequired && appVersion.Semver != nil && requiredSemverErr == nil { + if (*appVersion.Semver).GTE(requiredSemver) { + laterReleaseInstalled = true + break + } + } else { + // cursors can only be compared on the same channel + if appVersion.ChannelID != airgap.Spec.ChannelID { + continue + } + if appVersion.Cursor == nil { + return nil, errors.Errorf("cursor required but version %s does not have cursor", appVersion.UpdateCursor) + } + requiredCursor, err := cursor.NewCursor(requiredRelease.UpdateCursor) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse required update cursor %q", requiredRelease.UpdateCursor) + } + if (*appVersion.Cursor).After(requiredCursor) || (*appVersion.Cursor).Equal(requiredCursor) { + laterReleaseInstalled = true + break + } + } + } + + if !laterReleaseInstalled { + requiredUpdates = append([]string{requiredRelease.VersionLabel}, requiredUpdates...) + } else { + break + } + } + + return requiredUpdates, nil +} + +func getNonDeployableCause(requiredUpdates []string) string { + if len(requiredUpdates) == 0 { + return "" + } + versionLabels := []string{} + for _, versionLabel := range requiredUpdates { + versionLabels = append([]string{versionLabel}, versionLabels...) + } + versionLabelsStr := strings.Join(versionLabels, ", ") + if len(requiredUpdates) == 1 { + return fmt.Sprintf("This version cannot be deployed because version %s is required and must be deployed first.", versionLabelsStr) + } + return fmt.Sprintf("This version cannot be deployed because versions %s are required and must be deployed first.", versionLabelsStr) +} diff --git a/pkg/update/required_test.go b/pkg/update/required_test.go new file mode 100644 index 0000000000..973be2d57b --- /dev/null +++ b/pkg/update/required_test.go @@ -0,0 +1,207 @@ +package update + +import ( + "testing" + + "github.com/blang/semver" + downstreamtypes "github.com/replicatedhq/kots/pkg/api/downstream/types" + "github.com/replicatedhq/kots/pkg/cursor" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/stretchr/testify/require" +) + +func Test_getRequiredAirgapUpdates(t *testing.T) { + channelID := "channel-id" + tests := []struct { + name string + airgap *kotsv1beta1.Airgap + license *kotsv1beta1.License + installedVersions []*downstreamtypes.DownstreamVersion + channelChanged bool + wantSemver []string + wantNoSemver []string + }{ + { + name: "nothing is installed yet", + airgap: &kotsv1beta1.Airgap{ + Spec: kotsv1beta1.AirgapSpec{ + ChannelID: channelID, + RequiredReleases: []kotsv1beta1.AirgapReleaseMeta{ + { + VersionLabel: "0.1.123", + UpdateCursor: "123", + }, + }, + }, + }, + license: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{}, + }, + installedVersions: []*downstreamtypes.DownstreamVersion{}, + wantNoSemver: []string{}, + wantSemver: []string{}, + }, + { + name: "latest satisfies all prerequsites", + airgap: &kotsv1beta1.Airgap{ + Spec: kotsv1beta1.AirgapSpec{ + ChannelID: channelID, + RequiredReleases: []kotsv1beta1.AirgapReleaseMeta{ + { + VersionLabel: "0.1.123", + UpdateCursor: "123", + }, + { + VersionLabel: "0.1.120", + UpdateCursor: "120", + }, + { + VersionLabel: "0.1.115", + UpdateCursor: "115", + }, + }, + }, + }, + license: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{}, + }, + installedVersions: []*downstreamtypes.DownstreamVersion{ + { + ChannelID: channelID, + VersionLabel: "0.1.124", + UpdateCursor: "124", + }, + }, + wantNoSemver: []string{}, + wantSemver: []string{}, + }, + { + name: "need some prerequsites", + airgap: &kotsv1beta1.Airgap{ + Spec: kotsv1beta1.AirgapSpec{ + ChannelID: channelID, + RequiredReleases: []kotsv1beta1.AirgapReleaseMeta{ + { + VersionLabel: "0.1.123", + UpdateCursor: "123", + }, + { + VersionLabel: "0.1.120", + UpdateCursor: "120", + }, + { + VersionLabel: "0.1.115", + UpdateCursor: "115", + }, + }, + }, + }, + license: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{}, + }, + installedVersions: []*downstreamtypes.DownstreamVersion{ + { + ChannelID: channelID, + VersionLabel: "0.1.117", + UpdateCursor: "117", + }, + }, + wantNoSemver: []string{"0.1.120", "0.1.123"}, + wantSemver: []string{"0.1.120", "0.1.123"}, + }, + { + name: "need all prerequsites", + airgap: &kotsv1beta1.Airgap{ + Spec: kotsv1beta1.AirgapSpec{ + ChannelID: channelID, + RequiredReleases: []kotsv1beta1.AirgapReleaseMeta{ + { + VersionLabel: "0.1.123", + UpdateCursor: "123", + }, + { + VersionLabel: "0.1.120", + UpdateCursor: "120", + }, + { + VersionLabel: "0.1.115", + UpdateCursor: "115", + }, + }, + }, + }, + license: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{}, + }, + installedVersions: []*downstreamtypes.DownstreamVersion{ + { + ChannelID: channelID, + VersionLabel: "0.1.113", + UpdateCursor: "113", + }, + }, + wantNoSemver: []string{"0.1.115", "0.1.120", "0.1.123"}, + wantSemver: []string{"0.1.115", "0.1.120", "0.1.123"}, + }, + { + name: "check across multiple channels", + airgap: &kotsv1beta1.Airgap{ + Spec: kotsv1beta1.AirgapSpec{ + ChannelID: channelID, + RequiredReleases: []kotsv1beta1.AirgapReleaseMeta{ + { + VersionLabel: "0.1.123", + UpdateCursor: "123", + }, + { + VersionLabel: "0.1.120", + UpdateCursor: "120", + }, + { + VersionLabel: "0.1.115", + UpdateCursor: "115", + }, + }, + }, + }, + license: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{}, + }, + channelChanged: true, + installedVersions: []*downstreamtypes.DownstreamVersion{ + { + ChannelID: "different-channel", + VersionLabel: "0.1.117", + UpdateCursor: "117", + }, + }, + wantNoSemver: []string{}, + wantSemver: []string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := require.New(t) + + for _, v := range tt.installedVersions { + s := semver.MustParse(v.VersionLabel) + v.Semver = &s + + c := cursor.MustParse(v.UpdateCursor) + v.Cursor = &c + } + + // cursor based + tt.license.Spec.IsSemverRequired = false + got, err := getRequiredAirgapUpdates(tt.airgap, tt.license, tt.installedVersions, tt.channelChanged) + req.NoError(err) + req.Equal(tt.wantNoSemver, got) + + // semver based + tt.license.Spec.IsSemverRequired = true + got, err = getRequiredAirgapUpdates(tt.airgap, tt.license, tt.installedVersions, tt.channelChanged) + req.NoError(err) + req.Equal(tt.wantSemver, got) + }) + } +} diff --git a/pkg/update/types/types.go b/pkg/update/types/types.go new file mode 100644 index 0000000000..7f3a45ede6 --- /dev/null +++ b/pkg/update/types/types.go @@ -0,0 +1,14 @@ +package types + +import "time" + +type AvailableUpdate struct { + VersionLabel string `json:"versionLabel"` + UpdateCursor string `json:"updateCursor"` + ChannelID string `json:"channelId"` + IsRequired bool `json:"isRequired"` + UpstreamReleasedAt *time.Time `json:"upstreamReleasedAt,omitempty"` + ReleaseNotes string `json:"releaseNotes,omitempty"` + IsDeployable bool `json:"isDeployable,omitempty"` + NonDeployableCause string `json:"nonDeployableCause,omitempty"` +} diff --git a/pkg/update/update.go b/pkg/update/update.go new file mode 100644 index 0000000000..badc7aefa8 --- /dev/null +++ b/pkg/update/update.go @@ -0,0 +1,151 @@ +package update + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/pkg/errors" + apptypes "github.com/replicatedhq/kots/pkg/app/types" + "github.com/replicatedhq/kots/pkg/kotsutil" + "github.com/replicatedhq/kots/pkg/reporting" + storepkg "github.com/replicatedhq/kots/pkg/store" + "github.com/replicatedhq/kots/pkg/update/types" + upstreampkg "github.com/replicatedhq/kots/pkg/upstream" + upstreamtypes "github.com/replicatedhq/kots/pkg/upstream/types" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" +) + +// a ephemeral directory to store available updates +var availableUpdatesDir string + +func InitAvailableUpdatesDir() error { + d, err := os.MkdirTemp("", "kotsadm-available-updates") + if err != nil { + return errors.Wrap(err, "failed to make temp dir") + } + availableUpdatesDir = d + return nil +} + +func GetAvailableUpdates(kotsStore storepkg.Store, app *apptypes.App, license *kotsv1beta1.License) ([]types.AvailableUpdate, error) { + updateCursor, err := kotsStore.GetCurrentUpdateCursor(app.ID, license.Spec.ChannelID) + if err != nil { + return nil, errors.Wrap(err, "failed to get current update cursor") + } + + upstreamURI := fmt.Sprintf("replicated://%s", license.Spec.AppSlug) + fetchOptions := &upstreamtypes.FetchOptions{ + License: license, + LastUpdateCheckAt: app.LastUpdateCheckAt, + CurrentCursor: updateCursor, + CurrentChannelID: license.Spec.ChannelID, + CurrentChannelName: license.Spec.ChannelName, + ChannelChanged: app.ChannelChanged, + SortOrder: "desc", // get the latest updates first + ReportingInfo: reporting.GetReportingInfo(app.ID), + } + updates, err := upstreampkg.GetUpdatesUpstream(upstreamURI, fetchOptions) + if err != nil { + return nil, errors.Wrap(err, "failed to get updates") + } + + availableUpdates := []types.AvailableUpdate{} + for _, u := range updates.Updates { + deployable, cause := isUpdateDeployable(u.Cursor, updates.Updates) + availableUpdates = append(availableUpdates, types.AvailableUpdate{ + VersionLabel: u.VersionLabel, + UpdateCursor: u.Cursor, + ChannelID: u.ChannelID, + IsRequired: u.IsRequired, + UpstreamReleasedAt: u.ReleasedAt, + ReleaseNotes: u.ReleaseNotes, + IsDeployable: deployable, + NonDeployableCause: cause, + }) + } + + return availableUpdates, nil +} + +func GetAvailableAirgapUpdates(app *apptypes.App, license *kotsv1beta1.License) ([]types.AvailableUpdate, error) { + updates := []types.AvailableUpdate{} + if err := filepath.Walk(availableUpdatesDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if filepath.Ext(path) != ".airgap" { + return nil + } + + airgap, err := kotsutil.FindAirgapMetaInBundle(path) + if err != nil { + return errors.Wrap(err, "failed to find airgap metadata") + } + if airgap.Spec.AppSlug != license.Spec.AppSlug { + return nil + } + if airgap.Spec.ChannelID != license.Spec.ChannelID { + return nil + } + + deployable, nonDeployableCause, err := IsAirgapUpdateDeployable(app, airgap) + if err != nil { + return errors.Wrap(err, "failed to check if airgap update is deployable") + } + + updates = append(updates, types.AvailableUpdate{ + VersionLabel: airgap.Spec.VersionLabel, + UpdateCursor: airgap.Spec.UpdateCursor, + ChannelID: airgap.Spec.ChannelID, + IsRequired: airgap.Spec.IsRequired, + ReleaseNotes: airgap.Spec.ReleaseNotes, + IsDeployable: deployable, + NonDeployableCause: nonDeployableCause, + }) + + return nil + }); err != nil { + return nil, errors.Wrap(err, "failed to walk airgap root dir") + } + + return updates, nil +} + +func RegisterAirgapUpdate(appSlug string, airgapUpdate string) error { + airgap, err := kotsutil.FindAirgapMetaInBundle(airgapUpdate) + if err != nil { + return errors.Wrap(err, "failed to find airgap metadata in bundle") + } + destPath := getAirgapUpdatePath(appSlug, airgap.Spec.ChannelID, airgap.Spec.UpdateCursor) + if err := os.MkdirAll(filepath.Dir(destPath), 0744); err != nil { + return errors.Wrap(err, "failed to create update dir") + } + if err := os.Rename(airgapUpdate, destPath); err != nil { + return errors.Wrap(err, "failed to move airgap update to dest dir") + } + return nil +} + +func RemoveAirgapUpdate(appSlug string, channelID string, updateCursor string) error { + updatePath := getAirgapUpdatePath(appSlug, channelID, updateCursor) + if err := os.Remove(updatePath); err != nil && !os.IsNotExist(err) { + return errors.Wrap(err, "failed to remove") + } + return nil +} + +func GetAirgapUpdate(appSlug string, channelID string, updateCursor string) (string, error) { + updatePath := getAirgapUpdatePath(appSlug, channelID, updateCursor) + if _, err := os.Stat(updatePath); err != nil { + return "", errors.Wrap(err, "failed to stat") + } + return updatePath, nil +} + +func getAirgapUpdatePath(appSlug string, channelID string, updateCursor string) string { + return filepath.Join(availableUpdatesDir, appSlug, fmt.Sprintf("%s-%s.airgap", channelID, updateCursor)) +} diff --git a/pkg/update/update_test.go b/pkg/update/update_test.go new file mode 100644 index 0000000000..091dd8c725 --- /dev/null +++ b/pkg/update/update_test.go @@ -0,0 +1,261 @@ +package update + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/golang/mock/gomock" + apptypes "github.com/replicatedhq/kots/pkg/app/types" + storepkg "github.com/replicatedhq/kots/pkg/store" + mock_store "github.com/replicatedhq/kots/pkg/store/mock" + "github.com/replicatedhq/kots/pkg/update/types" + "github.com/replicatedhq/kots/pkg/upstream" + upstreamtypes "github.com/replicatedhq/kots/pkg/upstream/types" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetAvailableUpdates(t *testing.T) { + ctrl := gomock.NewController(t) + mockStore := mock_store.NewMockStore(ctrl) + + testTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + + type args struct { + kotsStore storepkg.Store + app *apptypes.App + license *kotsv1beta1.License + } + tests := []struct { + name string + args args + channelReleases []upstream.ChannelRelease + setup func(t *testing.T, args args, mockServerEndpoint string) + want []types.AvailableUpdate + wantErr bool + }{ + { + name: "no updates", + args: args{ + kotsStore: mockStore, + app: &apptypes.App{ + ID: "app-id", + }, + license: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + ChannelID: "channel-id", + ChannelName: "channel-name", + AppSlug: "app-slug", + LicenseID: "license-id", + }, + }, + }, + channelReleases: []upstream.ChannelRelease{}, + setup: func(t *testing.T, args args, licenseEndpoint string) { + t.Setenv("USE_MOCK_REPORTING", "1") + args.license.Spec.Endpoint = licenseEndpoint + mockStore.EXPECT().GetCurrentUpdateCursor(args.app.ID, args.license.Spec.ChannelID).Return("1", nil) + }, + want: []types.AvailableUpdate{}, + wantErr: false, + }, + { + name: "has updates", + args: args{ + kotsStore: mockStore, + app: &apptypes.App{ + ID: "app-id", + }, + license: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + ChannelID: "channel-id", + ChannelName: "channel-name", + AppSlug: "app-slug", + LicenseID: "license-id", + }, + }, + }, + channelReleases: []upstream.ChannelRelease{ + { + ChannelSequence: 2, + ReleaseSequence: 2, + VersionLabel: "0.0.2", + IsRequired: false, + CreatedAt: testTime.Format(time.RFC3339), + ReleaseNotes: "release notes", + }, + { + ChannelSequence: 1, + ReleaseSequence: 1, + VersionLabel: "0.0.1", + IsRequired: true, + CreatedAt: testTime.Format(time.RFC3339), + ReleaseNotes: "release notes", + }, + }, + setup: func(t *testing.T, args args, licenseEndpoint string) { + t.Setenv("USE_MOCK_REPORTING", "1") + args.license.Spec.Endpoint = licenseEndpoint + mockStore.EXPECT().GetCurrentUpdateCursor(args.app.ID, args.license.Spec.ChannelID).Return("1", nil) + }, + want: []types.AvailableUpdate{ + { + VersionLabel: "0.0.2", + UpdateCursor: "2", + ChannelID: "channel-id", + IsRequired: false, + UpstreamReleasedAt: &testTime, + ReleaseNotes: "release notes", + IsDeployable: false, + NonDeployableCause: "This version cannot be deployed because version 0.0.1 is required and must be deployed first.", + }, + { + VersionLabel: "0.0.1", + UpdateCursor: "1", + ChannelID: "channel-id", + IsRequired: true, + UpstreamReleasedAt: &testTime, + ReleaseNotes: "release notes", + IsDeployable: true, + }, + }, + wantErr: false, + }, + { + name: "fails to fetch updates", + args: args{ + kotsStore: mockStore, + app: &apptypes.App{ + ID: "app-id", + }, + license: &kotsv1beta1.License{ + Spec: kotsv1beta1.LicenseSpec{ + ChannelID: "channel-id", + ChannelName: "channel-name", + AppSlug: "app-slug", + LicenseID: "license-id", + }, + }, + }, + channelReleases: []upstream.ChannelRelease{}, + setup: func(t *testing.T, args args, licenseEndpoint string) { + t.Setenv("USE_MOCK_REPORTING", "1") + args.license.Spec.Endpoint = licenseEndpoint + mockStore.EXPECT().GetCurrentUpdateCursor(args.app.ID, args.license.Spec.ChannelID).Return("1", nil) + }, + want: []types.AvailableUpdate{}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := require.New(t) + mockServer := newMockServerWithReleases(tt.channelReleases, tt.wantErr) + defer mockServer.Close() + tt.setup(t, tt.args, mockServer.URL) + got, err := GetAvailableUpdates(tt.args.kotsStore, tt.args.app, tt.args.license) + if tt.wantErr { + req.Error(err) + return + } + req.NoError(err) + req.Equal(tt.want, got) + }) + } +} + +func newMockServerWithReleases(channelReleases []upstream.ChannelRelease, wantErr bool) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if wantErr { + http.Error(w, "error", http.StatusInternalServerError) + return + } + var response struct { + ChannelReleases []upstream.ChannelRelease `json:"channelReleases"` + } + response.ChannelReleases = channelReleases + w.Header().Set("X-Replicated-UpdateCheckAt", time.Now().Format(time.RFC3339)) + if err := json.NewEncoder(w).Encode(response); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + })) +} +func TestIsUpdateDeployable(t *testing.T) { + tests := []struct { + name string + updateCursor string + updates []upstreamtypes.Update + want bool + wantCause string + }{ + { + name: "one update", + updateCursor: "3", + updates: []upstreamtypes.Update{ + {VersionLabel: "1.0.3", Cursor: "3", IsRequired: false}, + }, + want: true, + wantCause: "", + }, + { + name: "no required updates", + updateCursor: "3", + updates: []upstreamtypes.Update{ + {VersionLabel: "1.0.4", Cursor: "4", IsRequired: false}, + {VersionLabel: "1.0.3", Cursor: "3", IsRequired: false}, + {VersionLabel: "1.0.2", Cursor: "2", IsRequired: false}, + {VersionLabel: "1.0.1", Cursor: "1", IsRequired: false}, + }, + want: true, + wantCause: "", + }, + { + name: "no required releases before it", + updateCursor: "3", + updates: []upstreamtypes.Update{ + {VersionLabel: "1.0.4", Cursor: "4", IsRequired: true}, + {VersionLabel: "1.0.3", Cursor: "3", IsRequired: false}, + {VersionLabel: "1.0.2", Cursor: "2", IsRequired: false}, + {VersionLabel: "1.0.1", Cursor: "1", IsRequired: false}, + }, + want: true, + wantCause: "", + }, + { + name: "one required release before it", + updateCursor: "3", + updates: []upstreamtypes.Update{ + {VersionLabel: "1.0.4", Cursor: "4", IsRequired: false}, + {VersionLabel: "1.0.3", Cursor: "3", IsRequired: false}, + {VersionLabel: "1.0.2", Cursor: "2", IsRequired: true}, + {VersionLabel: "1.0.1", Cursor: "1", IsRequired: false}, + }, + want: false, + wantCause: "This version cannot be deployed because version 1.0.2 is required and must be deployed first.", + }, + { + name: "two required releases before it", + updateCursor: "3", + updates: []upstreamtypes.Update{ + {VersionLabel: "1.0.4", Cursor: "4", IsRequired: false}, + {VersionLabel: "1.0.3", Cursor: "3", IsRequired: false}, + {VersionLabel: "1.0.2", Cursor: "2", IsRequired: true}, + {VersionLabel: "1.0.1", Cursor: "1", IsRequired: true}, + }, + want: false, + wantCause: "This version cannot be deployed because versions 1.0.2, 1.0.1 are required and must be deployed first.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, msg := isUpdateDeployable(tt.updateCursor, tt.updates) + assert.Equal(t, tt.want, result) + assert.Equal(t, tt.wantCause, msg) + }) + } +} diff --git a/pkg/updatechecker/types/types.go b/pkg/updatechecker/types/types.go new file mode 100644 index 0000000000..6f8dcfbeb7 --- /dev/null +++ b/pkg/updatechecker/types/types.go @@ -0,0 +1,24 @@ +package types + +type CheckForUpdatesOpts struct { + AppID string + DeployLatest bool + DeployVersionLabel string + IsAutomatic bool + SkipPreflights bool + SkipCompatibilityCheck bool + IsCLI bool + Wait bool +} + +type UpdateCheckResponse struct { + AvailableUpdates int64 + CurrentRelease *UpdateCheckRelease + AvailableReleases []UpdateCheckRelease + DeployingRelease *UpdateCheckRelease +} + +type UpdateCheckRelease struct { + Sequence int64 + Version string +} diff --git a/pkg/updatechecker/updatechecker.go b/pkg/updatechecker/updatechecker.go index b6265fd027..4e5d6ea032 100644 --- a/pkg/updatechecker/updatechecker.go +++ b/pkg/updatechecker/updatechecker.go @@ -15,13 +15,14 @@ import ( upstream "github.com/replicatedhq/kots/pkg/kotsadmupstream" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/preflight" - "github.com/replicatedhq/kots/pkg/preflight/types" + preflighttypes "github.com/replicatedhq/kots/pkg/preflight/types" kotspull "github.com/replicatedhq/kots/pkg/pull" "github.com/replicatedhq/kots/pkg/reporting" kotssemver "github.com/replicatedhq/kots/pkg/semver" storepkg "github.com/replicatedhq/kots/pkg/store" storetypes "github.com/replicatedhq/kots/pkg/store/types" "github.com/replicatedhq/kots/pkg/tasks" + "github.com/replicatedhq/kots/pkg/updatechecker/types" upstreamtypes "github.com/replicatedhq/kots/pkg/upstream/types" "github.com/replicatedhq/kots/pkg/util" "github.com/replicatedhq/kots/pkg/version" @@ -113,7 +114,7 @@ func Configure(a *apptypes.App, updateCheckerSpec string) error { _, err := job.AddFunc(cronSpec, func() { logger.Debug("checking updates for app", zap.String("slug", jobAppSlug)) - opts := CheckForUpdatesOpts{ + opts := types.CheckForUpdatesOpts{ AppID: jobAppID, IsAutomatic: true, } @@ -155,42 +156,19 @@ func Stop(appID string) { } } -type CheckForUpdatesOpts struct { - AppID string - DeployLatest bool - DeployVersionLabel string - IsAutomatic bool - SkipPreflights bool - SkipCompatibilityCheck bool - IsCLI bool - Wait bool -} - -type UpdateCheckResponse struct { - AvailableUpdates int64 - CurrentRelease *UpdateCheckRelease - AvailableReleases []UpdateCheckRelease - DeployingRelease *UpdateCheckRelease -} - -type UpdateCheckRelease struct { - Sequence int64 - Version string -} - // CheckForUpdates checks, downloads, and makes sure the desired version for a specific app is deployed. // if "DeployLatest" is set to true, the latest version will be deployed. // otherwise, if "DeployVersionLabel" is set to true, then the version with the corresponding version label will be deployed (if found). // otherwise, if "IsAutomatic" is set to true (which means it's an automatic update check), then the version that matches the auto deploy configuration (if enabled) will be deployed. // Automatic update checks will not run on embedded clusters. // returns the number of available updates. -func CheckForUpdates(opts CheckForUpdatesOpts) (ucr *UpdateCheckResponse, finalError error) { +func CheckForUpdates(opts types.CheckForUpdatesOpts) (ucr *types.UpdateCheckResponse, finalError error) { if opts.IsAutomatic && util.IsEmbeddedCluster() { logger.Debugf("skipping automatic update check for app %s because it's running in an embedded cluster", opts.AppID) return } - currentStatus, _, err := store.GetTaskStatus("update-download") + currentStatus, _, err := tasks.GetTaskStatus("update-download") if err != nil { return nil, errors.Wrap(err, "failed to get task status") } @@ -199,7 +177,7 @@ func CheckForUpdates(opts CheckForUpdatesOpts) (ucr *UpdateCheckResponse, finalE return nil, nil } - if err := store.SetTaskStatus("update-download", "Checking for updates...", "running"); err != nil { + if err := tasks.SetTaskStatus("update-download", "Checking for updates...", "running"); err != nil { return nil, errors.Wrap(err, "failed to set task status") } @@ -212,7 +190,7 @@ func CheckForUpdates(opts CheckForUpdatesOpts) (ucr *UpdateCheckResponse, finalE } }() - tasks.StartUpdateTaskMonitor("update-download", finishedChan) + tasks.StartTaskMonitor("update-download", finishedChan) ucr, finalError = checkForKotsAppUpdates(opts, finishedChan) if finalError != nil { @@ -223,7 +201,7 @@ func CheckForUpdates(opts CheckForUpdatesOpts) (ucr *UpdateCheckResponse, finalE return } -func checkForKotsAppUpdates(opts CheckForUpdatesOpts, finishedChan chan<- error) (*UpdateCheckResponse, error) { +func checkForKotsAppUpdates(opts types.CheckForUpdatesOpts, finishedChan chan<- error) (*types.UpdateCheckResponse, error) { a, err := store.GetApp(opts.AppID) if err != nil { return nil, errors.Wrap(err, "failed to get app") @@ -283,24 +261,24 @@ func checkForKotsAppUpdates(opts CheckForUpdatesOpts, finishedChan chan<- error) filteredUpdates := removeOldUpdates(updates.Updates, appVersions, latestLicense.Spec.IsSemverRequired) - var availableReleases []UpdateCheckRelease + var availableReleases []types.UpdateCheckRelease availableSequence := appVersions.AllVersions[0].Sequence + 1 for _, u := range filteredUpdates { - availableReleases = append(availableReleases, UpdateCheckRelease{ + availableReleases = append(availableReleases, types.UpdateCheckRelease{ Sequence: availableSequence, Version: u.VersionLabel, }) availableSequence++ } - ucr := UpdateCheckResponse{ + ucr := types.UpdateCheckResponse{ AvailableUpdates: int64(len(filteredUpdates)), AvailableReleases: availableReleases, DeployingRelease: getVersionToDeploy(opts, d.ClusterID, availableReleases), } if appVersions.CurrentVersion != nil { - ucr.CurrentRelease = &UpdateCheckRelease{ + ucr.CurrentRelease = &types.UpdateCheckRelease{ Sequence: appVersions.CurrentVersion.Sequence, Version: appVersions.CurrentVersion.VersionLabel, } @@ -318,7 +296,7 @@ func checkForKotsAppUpdates(opts CheckForUpdatesOpts, finishedChan chan<- error) // this is to avoid a race condition where the UI polls the task status before it is set by the goroutine status := fmt.Sprintf("%d Updates available...", ucr.AvailableUpdates) - if err := store.SetTaskStatus("update-download", status, "running"); err != nil { + if err := tasks.SetTaskStatus("update-download", status, "running"); err != nil { return nil, errors.Wrap(err, "failed to set task status") } @@ -340,7 +318,7 @@ func checkForKotsAppUpdates(opts CheckForUpdatesOpts, finishedChan chan<- error) return &ucr, nil } -func downloadAppUpdates(opts CheckForUpdatesOpts, appID string, clusterID string, updates []upstreamtypes.Update, updateCheckTime time.Time) error { +func downloadAppUpdates(opts types.CheckForUpdatesOpts, appID string, clusterID string, updates []upstreamtypes.Update, updateCheckTime time.Time) error { for index, update := range updates { appSequence, err := upstream.DownloadUpdate(appID, update, opts.SkipPreflights, opts.SkipCompatibilityCheck) if appSequence != nil { @@ -368,7 +346,7 @@ func downloadAppUpdates(opts CheckForUpdatesOpts, appID string, clusterID string return nil } -func ensureDesiredVersionIsDeployed(opts CheckForUpdatesOpts, clusterID string) error { +func ensureDesiredVersionIsDeployed(opts types.CheckForUpdatesOpts, clusterID string) error { if opts.DeployLatest { if err := deployLatestVersion(opts, clusterID); err != nil { return errors.Wrap(err, "failed to deploy latest version") @@ -397,7 +375,7 @@ func ensureDesiredVersionIsDeployed(opts CheckForUpdatesOpts, clusterID string) return nil } -func getVersionToDeploy(opts CheckForUpdatesOpts, clusterID string, availableReleases []UpdateCheckRelease) *UpdateCheckRelease { +func getVersionToDeploy(opts types.CheckForUpdatesOpts, clusterID string, availableReleases []types.UpdateCheckRelease) *types.UpdateCheckRelease { appVersions, err := store.GetDownstreamVersions(opts.AppID, clusterID, true) if err != nil { return nil @@ -412,7 +390,7 @@ func getVersionToDeploy(opts CheckForUpdatesOpts, clusterID string, availableRel } if opts.DeployLatest && appVersions.AllVersions[0].Sequence != appVersions.CurrentVersion.Sequence { - return &UpdateCheckRelease{ + return &types.UpdateCheckRelease{ Sequence: appVersions.AllVersions[0].Sequence, Version: appVersions.AllVersions[0].VersionLabel, } @@ -428,7 +406,7 @@ func getVersionToDeploy(opts CheckForUpdatesOpts, clusterID string, availableRel } if versionToDeploy != nil && versionToDeploy.Sequence != appVersions.CurrentVersion.Sequence { - return &UpdateCheckRelease{ + return &types.UpdateCheckRelease{ Sequence: versionToDeploy.Sequence, Version: versionToDeploy.VersionLabel, } @@ -440,7 +418,7 @@ func getVersionToDeploy(opts CheckForUpdatesOpts, clusterID string, availableRel return nil } -func deployLatestVersion(opts CheckForUpdatesOpts, clusterID string) error { +func deployLatestVersion(opts types.CheckForUpdatesOpts, clusterID string) error { appVersions, err := store.GetDownstreamVersions(opts.AppID, clusterID, true) if err != nil { return errors.Wrapf(err, "failed to get app versions for app %s", opts.AppID) @@ -457,7 +435,7 @@ func deployLatestVersion(opts CheckForUpdatesOpts, clusterID string) error { return nil } -func deployVersionLabel(opts CheckForUpdatesOpts, clusterID string, versionLabel string) error { +func deployVersionLabel(opts types.CheckForUpdatesOpts, clusterID string, versionLabel string) error { appVersions, err := store.GetDownstreamVersions(opts.AppID, clusterID, true) if err != nil { return errors.Wrapf(err, "failed to get app versions for app %s", opts.AppID) @@ -486,7 +464,7 @@ func deployVersionLabel(opts CheckForUpdatesOpts, clusterID string, versionLabel return nil } -func autoDeploy(opts CheckForUpdatesOpts, clusterID string, autoDeploy apptypes.AutoDeploy) error { +func autoDeploy(opts types.CheckForUpdatesOpts, clusterID string, autoDeploy apptypes.AutoDeploy) error { if autoDeploy == "" || autoDeploy == apptypes.AutoDeployDisabled { return nil } @@ -596,7 +574,7 @@ func waitForPreflightsToFinish(appID string, sequence int64) error { return errors.New("failed to find a preflight spec") } - var preflightResults *types.PreflightResults + var preflightResults *preflighttypes.PreflightResults if err = json.Unmarshal([]byte(preflightResult.Result), &preflightResults); err != nil { return errors.Wrap(err, "failed to parse preflight results") } @@ -609,7 +587,7 @@ func waitForPreflightsToFinish(appID string, sequence int64) error { return nil } -func deployVersion(opts CheckForUpdatesOpts, clusterID string, appVersions *downstreamtypes.DownstreamVersions, versionToDeploy *downstreamtypes.DownstreamVersion) error { +func deployVersion(opts types.CheckForUpdatesOpts, clusterID string, appVersions *downstreamtypes.DownstreamVersions, versionToDeploy *downstreamtypes.DownstreamVersion) error { if appVersions.CurrentVersion != nil { isPastVersion := false for _, p := range appVersions.PastVersions { diff --git a/pkg/updatechecker/updatechecker_test.go b/pkg/updatechecker/updatechecker_test.go index 773424f519..aa0ed245f9 100644 --- a/pkg/updatechecker/updatechecker_test.go +++ b/pkg/updatechecker/updatechecker_test.go @@ -13,13 +13,14 @@ import ( preflighttypes "github.com/replicatedhq/kots/pkg/preflight/types" mock_store "github.com/replicatedhq/kots/pkg/store/mock" storetypes "github.com/replicatedhq/kots/pkg/store/types" + "github.com/replicatedhq/kots/pkg/updatechecker/types" upstreamtypes "github.com/replicatedhq/kots/pkg/upstream/types" "github.com/stretchr/testify/require" ) func TestAutoDeployDoesNotExecuteIfDisabled(t *testing.T) { var autoDeployType = apptypes.AutoDeployDisabled - var opts = CheckForUpdatesOpts{} + var opts = types.CheckForUpdatesOpts{} err := autoDeploy(opts, "cluster-id", autoDeployType) if err != nil { @@ -28,7 +29,7 @@ func TestAutoDeployDoesNotExecuteIfDisabled(t *testing.T) { } func TestAutoDeployDoesNotExecuteIfNotSet(t *testing.T) { - var opts = CheckForUpdatesOpts{} + var opts = types.CheckForUpdatesOpts{} var clusterID = "some-cluster-id" err := autoDeploy(opts, clusterID, "") @@ -41,7 +42,7 @@ func TestAutoDeployFailedToGetAppVersionsErrors(t *testing.T) { var autoDeployType = apptypes.AutoDeploySemverPatch var appID = "some-app" var clusterID = "some-cluster-id" - var opts = CheckForUpdatesOpts{AppID: appID} + var opts = types.CheckForUpdatesOpts{AppID: appID} var downstreamVersions = &downstreamtypes.DownstreamVersions{} ctrl := gomock.NewController(t) @@ -64,7 +65,7 @@ func TestAutoDeployAppVersionsIsEmptyErrors(t *testing.T) { var autoDeployType = apptypes.AutoDeploySemverPatch var appID = "some-app" var clusterID = "some-cluster-id" - var opts = CheckForUpdatesOpts{AppID: appID} + var opts = types.CheckForUpdatesOpts{AppID: appID} var downstreamVersions = &downstreamtypes.DownstreamVersions{ AllVersions: []*downstreamtypes.DownstreamVersion{}, } @@ -86,7 +87,7 @@ func TestAutoDeployCurrentVersionIsNilDoesNothing(t *testing.T) { var autoDeployType = apptypes.AutoDeploySemverPatch var appID = "some-app" var clusterID = "some-cluster-id" - var opts = CheckForUpdatesOpts{AppID: appID} + var opts = types.CheckForUpdatesOpts{AppID: appID} var downstreamVersions = &downstreamtypes.DownstreamVersions{ CurrentVersion: nil, AllVersions: []*downstreamtypes.DownstreamVersion{ @@ -111,7 +112,7 @@ func TestAutoDeployCurrentVersionSemverIsNilDoesNothing(t *testing.T) { var autoDeployType = apptypes.AutoDeploySemverPatch var appID = "some-app" var clusterID = "some-cluster-id" - var opts = CheckForUpdatesOpts{AppID: appID} + var opts = types.CheckForUpdatesOpts{AppID: appID} var downstreamVersions = &downstreamtypes.DownstreamVersions{ CurrentVersion: &downstreamtypes.DownstreamVersion{ Semver: nil, @@ -140,7 +141,7 @@ func TestAutoDeploySequenceQuitsIfCurrentVersionSequenceIsGreaterThanOrEqualToMo var clusterID = "some-cluster-id" var currentSequence = int64(1) var upgradeSequence = int64(1) - var opts = CheckForUpdatesOpts{AppID: appID} + var opts = types.CheckForUpdatesOpts{AppID: appID} var downstreamVersions = &downstreamtypes.DownstreamVersions{ CurrentVersion: &downstreamtypes.DownstreamVersion{ Semver: &semver.Version{}, @@ -170,7 +171,7 @@ func TestAutoDeploySequenceDeploysSequenceUpgradeIfCurrentVersionLessThanMostRec var autoDeployType = apptypes.AutoDeploySequence var appID = "some-app" var clusterID = "some-cluster-id" - var opts = CheckForUpdatesOpts{AppID: appID} + var opts = types.CheckForUpdatesOpts{AppID: appID} var currentCursor = cursor.MustParse("1") var upgradeCursor = cursor.MustParse("2") var downstreamVersions = &downstreamtypes.DownstreamVersions{ @@ -204,7 +205,7 @@ func TestAutoDeploySequenceDoesNotDeployIfCurrentVersionIsSameUpstream(t *testin var autoDeployType = apptypes.AutoDeploySequence var appID = "some-app" var clusterID = "some-cluster-id" - var opts = CheckForUpdatesOpts{AppID: appID} + var opts = types.CheckForUpdatesOpts{AppID: appID} var currentCursor = cursor.MustParse("2") var upgradeCursor = cursor.MustParse("2") var downstreamVersions = &downstreamtypes.DownstreamVersions{ @@ -237,7 +238,7 @@ func TestAutoDeploySemverRequiredAllVersionsIndexIsNil(t *testing.T) { var autoDeployType = apptypes.AutoDeploySemverPatch var appID = "some-app" var clusterID = "some-cluster-id" - var opts = CheckForUpdatesOpts{AppID: appID} + var opts = types.CheckForUpdatesOpts{AppID: appID} var downstreamVersions = &downstreamtypes.DownstreamVersions{ CurrentVersion: &downstreamtypes.DownstreamVersion{ Semver: &semver.Version{}, @@ -263,7 +264,7 @@ func TestAutoDeploySemverRequiredAllVersionsHasNilSemver(t *testing.T) { var autoDeployType = apptypes.AutoDeploySemverPatch var appID = "some-app" var clusterID = "some-cluster-id" - var opts = CheckForUpdatesOpts{AppID: appID} + var opts = types.CheckForUpdatesOpts{AppID: appID} var downstreamVersions = &downstreamtypes.DownstreamVersions{ CurrentVersion: &downstreamtypes.DownstreamVersion{ Semver: &semver.Version{}, @@ -296,7 +297,7 @@ func TestAutoDeploySemverRequiredNoNewVersionToDeploy(t *testing.T) { var major = uint64(1) var minor = uint64(2) var patch = uint64(1) - var opts = CheckForUpdatesOpts{AppID: appID} + var opts = types.CheckForUpdatesOpts{AppID: appID} var downstreamVersions = &downstreamtypes.DownstreamVersions{ CurrentVersion: &downstreamtypes.DownstreamVersion{ Semver: &semver.Version{ @@ -336,7 +337,7 @@ func TestAutoDeploySemverRequiredPatchUpdateMajorsDontMatch(t *testing.T) { var clusterID = "some-cluster-id" var currentMajor = uint64(1) var updateMajor = uint64(2) - var opts = CheckForUpdatesOpts{AppID: appID} + var opts = types.CheckForUpdatesOpts{AppID: appID} var downstreamVersions = &downstreamtypes.DownstreamVersions{ CurrentVersion: &downstreamtypes.DownstreamVersion{ Semver: &semver.Version{ @@ -373,7 +374,7 @@ func TestAutoDeploySemverRequiredPatchUpdateMajorsMatchMinorsDontMatch(t *testin var major = uint64(1) var currentMinor = uint64(2) var updateMinor = uint64(2) - var opts = CheckForUpdatesOpts{AppID: appID} + var opts = types.CheckForUpdatesOpts{AppID: appID} var downstreamVersions = &downstreamtypes.DownstreamVersions{ CurrentVersion: &downstreamtypes.DownstreamVersion{ Semver: &semver.Version{ @@ -414,7 +415,7 @@ func TestAutoDeploySemverRequiredPatchUpdateMajorsMatchMinorsMatchWillUpgrade(t var minor = uint64(2) var currentPatch = uint64(1) var upgradePatch = uint64(2) - var opts = CheckForUpdatesOpts{AppID: appID} + var opts = types.CheckForUpdatesOpts{AppID: appID} var downstreamVersions = &downstreamtypes.DownstreamVersions{ CurrentVersion: &downstreamtypes.DownstreamVersion{ Semver: &semver.Version{ @@ -456,7 +457,7 @@ func TestAutoDeploySemverRequiredMinorUpdateMajorsDontMatch(t *testing.T) { var sequence = int64(0) var currentMajor = uint64(1) var upgradeMajor = uint64(2) - var opts = CheckForUpdatesOpts{AppID: appID} + var opts = types.CheckForUpdatesOpts{AppID: appID} var downstreamVersions = &downstreamtypes.DownstreamVersions{ CurrentVersion: &downstreamtypes.DownstreamVersion{ Semver: &semver.Version{ @@ -495,7 +496,7 @@ func TestAutoDeploySemverRequiredMinorUpdateMajorsMatchWillUpgrade(t *testing.T) var major = uint64(1) var currentMinor = uint64(1) var upgradeMinor = uint64(2) - var opts = CheckForUpdatesOpts{AppID: appID} + var opts = types.CheckForUpdatesOpts{AppID: appID} var downstreamVersions = &downstreamtypes.DownstreamVersions{ CurrentVersion: &downstreamtypes.DownstreamVersion{ Semver: &semver.Version{ @@ -535,7 +536,7 @@ func TestAutoDeploySemverRequiredMajorUpdateWillUpgrade(t *testing.T) { var sequence = int64(0) var currentMajor = uint64(1) var upgradeMajor = uint64(2) - var opts = CheckForUpdatesOpts{AppID: appID} + var opts = types.CheckForUpdatesOpts{AppID: appID} var downstreamVersions = &downstreamtypes.DownstreamVersions{ CurrentVersion: &downstreamtypes.DownstreamVersion{ Semver: &semver.Version{ diff --git a/pkg/upgradeservice/bootstrap.go b/pkg/upgradeservice/bootstrap.go new file mode 100644 index 0000000000..7109de06f6 --- /dev/null +++ b/pkg/upgradeservice/bootstrap.go @@ -0,0 +1,148 @@ +package upgradeservice + +import ( + "bufio" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/archives" + identity "github.com/replicatedhq/kots/pkg/kotsadmidentity" + "github.com/replicatedhq/kots/pkg/kotsutil" + "github.com/replicatedhq/kots/pkg/logger" + "github.com/replicatedhq/kots/pkg/pull" + registrytypes "github.com/replicatedhq/kots/pkg/registry/types" + "github.com/replicatedhq/kots/pkg/upgradeservice/task" + "github.com/replicatedhq/kots/pkg/upgradeservice/types" + "github.com/replicatedhq/kots/pkg/util" +) + +func bootstrap(params types.UpgradeServiceParams) (finalError error) { + if params.AppIsAirgap { + if err := pullArchiveFromAirgap(params); err != nil { + return errors.Wrap(err, "failed to pull archive from airgap") + } + } else { + if err := pullArchiveFromOnline(params); err != nil { + return errors.Wrap(err, "failed to pull archive from online") + } + } + return nil +} + +func pullArchiveFromAirgap(params types.UpgradeServiceParams) (finalError error) { + airgapRoot, err := archives.ExtractAppMetaFromAirgapBundle(params.UpdateAirgapBundle) + if err != nil { + return errors.Wrap(err, "failed to extract archive") + } + defer os.RemoveAll(airgapRoot) + + pullOptions := pull.PullOptions{ + IsAirgap: true, + AirgapRoot: airgapRoot, + AirgapBundle: params.UpdateAirgapBundle, + Silent: true, + } + if err := pullArchive(params, pullOptions); err != nil { + return errors.Wrap(err, "failed to pull") + } + return nil +} + +func pullArchiveFromOnline(params types.UpgradeServiceParams) (finalError error) { + pullOptions := pull.PullOptions{ + IsGitOps: params.AppIsGitOps, + ReportingInfo: params.ReportingInfo, + } + if err := pullArchive(params, pullOptions); err != nil { + return errors.Wrap(err, "failed to pull") + } + return nil +} + +func pullArchive(params types.UpgradeServiceParams, pullOptions pull.PullOptions) (finalError error) { + license, err := kotsutil.LoadLicenseFromBytes([]byte(params.AppLicense)) + if err != nil { + return errors.Wrap(err, "failed to load license from bytes") + } + + identityConfigFile, err := getIdentityConfigFile(params) + if err != nil { + return errors.Wrap(err, "failed to get identity config file") + } + + beforeKotsKinds, err := kotsutil.LoadKotsKinds(params.AppArchive) + if err != nil { + return errors.Wrap(err, "failed to load current kotskinds") + } + + if err := pull.CleanBaseArchive(params.AppArchive); err != nil { + return errors.Wrap(err, "failed to clean base archive") + } + + registrySettings := registrytypes.RegistrySettings{ + Hostname: params.RegistryEndpoint, + Username: params.RegistryUsername, + Password: params.RegistryPassword, + Namespace: params.RegistryNamespace, + IsReadOnly: params.RegistryIsReadOnly, + } + + pipeReader, pipeWriter := io.Pipe() + defer func() { + pipeWriter.CloseWithError(finalError) + }() + go func() { + scanner := bufio.NewScanner(pipeReader) + for scanner.Scan() { + if err := task.SetStatusStarting(params.AppSlug, scanner.Text()); err != nil { + logger.Error(err) + } + } + pipeReader.CloseWithError(scanner.Err()) + }() + + // common options + pullOptions.LicenseObj = license + pullOptions.Namespace = util.AppNamespace() + pullOptions.ConfigFile = filepath.Join(params.AppArchive, "upstream", "userdata", "config.yaml") + pullOptions.InstallationFile = filepath.Join(params.AppArchive, "upstream", "userdata", "installation.yaml") + pullOptions.IdentityConfigFile = identityConfigFile + pullOptions.UpdateCursor = params.UpdateCursor + pullOptions.RootDir = params.AppArchive + pullOptions.Downstreams = []string{"this-cluster"} + pullOptions.ExcludeKotsKinds = true + pullOptions.ExcludeAdminConsole = true + pullOptions.CreateAppDir = false + pullOptions.ReportWriter = pipeWriter + pullOptions.AppID = params.AppID + pullOptions.AppSlug = params.AppSlug + pullOptions.AppSequence = params.NextSequence + pullOptions.RewriteImages = registrySettings.IsValid() + pullOptions.RewriteImageOptions = registrySettings + pullOptions.KotsKinds = beforeKotsKinds + + _, err = pull.Pull(fmt.Sprintf("replicated://%s", license.Spec.AppSlug), pullOptions) + if err != nil { + return errors.Wrap(err, "failed to pull") + } + + return nil +} + +func getIdentityConfigFile(params types.UpgradeServiceParams) (string, error) { + identityConfigFile := filepath.Join(params.AppArchive, "upstream", "userdata", "identityconfig.yaml") + if _, err := os.Stat(identityConfigFile); os.IsNotExist(err) { + file, err := identity.InitAppIdentityConfig(params.AppSlug) + if err != nil { + return "", errors.Wrap(err, "failed to init identity config") + } + identityConfigFile = file + defer os.Remove(identityConfigFile) + } else if err != nil { + return "", errors.Wrap(err, "failed to get stat identity config file") + } + return identityConfigFile, nil +} diff --git a/pkg/upgradeservice/deploy/deploy.go b/pkg/upgradeservice/deploy/deploy.go new file mode 100644 index 0000000000..a55bab57de --- /dev/null +++ b/pkg/upgradeservice/deploy/deploy.go @@ -0,0 +1,248 @@ +package deploy + +import ( + "context" + "fmt" + "time" + + "github.com/pkg/errors" + embeddedclusterv1beta1 "github.com/replicatedhq/embedded-cluster-kinds/apis/v1beta1" + "github.com/replicatedhq/kots/pkg/apparchive" + "github.com/replicatedhq/kots/pkg/embeddedcluster" + "github.com/replicatedhq/kots/pkg/k8sutil" + kotsadmtypes "github.com/replicatedhq/kots/pkg/kotsadm/types" + kotsadmconfig "github.com/replicatedhq/kots/pkg/kotsadmconfig" + "github.com/replicatedhq/kots/pkg/kotsutil" + "github.com/replicatedhq/kots/pkg/logger" + registrytypes "github.com/replicatedhq/kots/pkg/registry/types" + "github.com/replicatedhq/kots/pkg/tasks" + upgradepreflight "github.com/replicatedhq/kots/pkg/upgradeservice/preflight" + "github.com/replicatedhq/kots/pkg/upgradeservice/task" + "github.com/replicatedhq/kots/pkg/upgradeservice/types" + "github.com/replicatedhq/kots/pkg/util" + corev1 "k8s.io/api/core/v1" + kuberneteserrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type CanDeployOptions struct { + Params types.UpgradeServiceParams + KotsKinds *kotsutil.KotsKinds + RegistrySettings registrytypes.RegistrySettings +} + +func CanDeploy(opts CanDeployOptions) (bool, string, error) { + needsConfig, err := kotsadmconfig.NeedsConfiguration( + opts.Params.AppSlug, + opts.Params.NextSequence, + opts.Params.AppIsAirgap, + opts.KotsKinds, + opts.RegistrySettings, + ) + if err != nil { + return false, "", errors.Wrap(err, "failed to check if version needs configuration") + } + if needsConfig { + return false, "cannot deploy because version needs configuration", nil + } + + pd, err := upgradepreflight.GetPreflightData() + if err != nil { + return false, "", errors.Wrap(err, "failed to get preflight data") + } + if pd.Result != nil && pd.Result.HasFailingStrictPreflights { + return false, "cannot deploy because a strict preflight check has failed", nil + } + + return true, "", nil +} + +type DeployOptions struct { + Ctx context.Context + IsSkipPreflights bool + ContinueWithFailedPreflights bool + Params types.UpgradeServiceParams + KotsKinds *kotsutil.KotsKinds + RegistrySettings registrytypes.RegistrySettings +} + +func Deploy(opts DeployOptions) error { + // put the app version archive in the object store so the operator + // of the new kots version can retrieve it to deploy the app + tgzArchiveKey := fmt.Sprintf( + "deployments/%s/%s-%s.tar.gz", + opts.Params.AppSlug, + opts.Params.UpdateChannelID, + opts.Params.UpdateCursor, + ) + if err := apparchive.CreateAppVersionArchive(opts.Params.AppArchive, tgzArchiveKey); err != nil { + return errors.Wrap(err, "failed to create app version archive") + } + + kbClient, err := k8sutil.GetKubeClient(opts.Ctx) + if err != nil { + return fmt.Errorf("failed to get kubeclient: %w", err) + } + + rcu, err := embeddedcluster.RequiresClusterUpgrade(opts.Ctx, kbClient, opts.KotsKinds) + if err != nil { + return errors.Wrap(err, "failed to check if cluster requires upgrade") + } + if !rcu { + // a cluster upgrade is not required so we can proceed with deploying the app + if err := createDeployment(createDeploymentOptions{ + ctx: opts.Ctx, + isSkipPreflights: opts.IsSkipPreflights, + continueWithFailedPreflights: opts.ContinueWithFailedPreflights, + params: opts.Params, + tgzArchiveKey: tgzArchiveKey, + requiresClusterUpgrade: false, + }); err != nil { + return errors.Wrap(err, "failed to create deployment") + } + // wait for deployment to be processed by the kots operator + if err := waitForDeployment(opts.Ctx, opts.Params.AppSlug); err != nil { + return errors.Wrap(err, "failed to wait for deployment") + } + return nil + } + + // a cluster upgrade is required. that's a long running process, and there's a high chance + // kots will be upgraded and restart during the process. so we run the upgrade in a goroutine + // and report the status back to the ui for the user to see the progress. + // the kots operator takes care of reporting the progress after the deployment gets created + + if err := task.SetStatusUpgradingCluster(opts.Params.AppSlug, embeddedclusterv1beta1.InstallationStateEnqueued); err != nil { + return errors.Wrap(err, "failed to set task status") + } + + go func() (finalError error) { + defer func() { + if finalError != nil { + if err := task.SetStatusUpgradeFailed(opts.Params.AppSlug, finalError.Error()); err != nil { + logger.Error(errors.Wrap(err, "failed to set task status to upgrade failed")) + } + } + }() + + finishedCh := make(chan struct{}) + defer close(finishedCh) + tasks.StartTicker(task.GetID(opts.Params.AppSlug), finishedCh) + + if err := embeddedcluster.StartClusterUpgrade(context.Background(), opts.KotsKinds, opts.RegistrySettings); err != nil { + return errors.Wrap(err, "failed to start cluster upgrade") + } + + if err := createDeployment(createDeploymentOptions{ + ctx: context.Background(), + isSkipPreflights: opts.IsSkipPreflights, + continueWithFailedPreflights: opts.ContinueWithFailedPreflights, + params: opts.Params, + tgzArchiveKey: tgzArchiveKey, + requiresClusterUpgrade: true, + }); err != nil { + return errors.Wrap(err, "failed to create deployment") + } + + return nil + }() + + return nil +} + +type createDeploymentOptions struct { + ctx context.Context + isSkipPreflights bool + continueWithFailedPreflights bool + params types.UpgradeServiceParams + tgzArchiveKey string + requiresClusterUpgrade bool +} + +// createDeployment creates a configmap with the app version info which gets detected by the operator of the new kots version to deploy the app. +func createDeployment(opts createDeploymentOptions) error { + clientset, err := k8sutil.GetClientset() + if err != nil { + return errors.Wrap(err, "failed to get clientset") + } + + preflightData, err := upgradepreflight.GetPreflightData() + if err != nil { + return errors.Wrap(err, "failed to get preflight data") + } + + preflightResult := "" + if preflightData.Result != nil { + preflightResult = preflightData.Result.Result + } + + cm := &corev1.ConfigMap{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "v1", + Kind: "ConfigMap", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: getDeploymentName(opts.params.AppSlug), + Labels: map[string]string{ + // exclude from backup so this app version is not deployed on restore + kotsadmtypes.ExcludeKey: kotsadmtypes.ExcludeValue, + "kots.io/deployment": "true", + "kots.io/processed": "false", + }, + }, + Data: map[string]string{ + "app-id": opts.params.AppID, + "app-slug": opts.params.AppSlug, + "app-version-archive": opts.tgzArchiveKey, + "base-sequence": fmt.Sprintf("%d", opts.params.BaseSequence), + "version-label": opts.params.UpdateVersionLabel, + "source": opts.params.Source, + "is-airgap": fmt.Sprintf("%t", opts.params.AppIsAirgap), + "channel-id": opts.params.UpdateChannelID, + "update-cursor": opts.params.UpdateCursor, + "skip-preflights": fmt.Sprintf("%t", opts.isSkipPreflights), + "continue-with-failed-preflights": fmt.Sprintf("%t", opts.continueWithFailedPreflights), + "preflight-result": preflightResult, + "embedded-cluster-version": opts.params.UpdateECVersion, + "requires-cluster-upgrade": fmt.Sprintf("%t", opts.requiresClusterUpgrade), + }, + } + + err = clientset.CoreV1().ConfigMaps(util.PodNamespace).Delete(opts.ctx, cm.Name, metav1.DeleteOptions{}) + if err != nil && !kuberneteserrors.IsNotFound(err) { + return errors.Wrap(err, "failed to delete configmap") + } + + _, err = clientset.CoreV1().ConfigMaps(util.PodNamespace).Create(opts.ctx, cm, metav1.CreateOptions{}) + if err != nil { + return errors.Wrap(err, "failed to create configmap") + } + + return nil +} + +// waitForDeployment waits for the deployment to be processed by the kots operator. +// this is only used when a cluster upgrade is not required. +func waitForDeployment(ctx context.Context, appSlug string) error { + clientset, err := k8sutil.GetClientset() + if err != nil { + return errors.Wrap(err, "failed to get clientset") + } + start := time.Now() + for { + cm, err := clientset.CoreV1().ConfigMaps(util.PodNamespace).Get(ctx, getDeploymentName(appSlug), metav1.GetOptions{}) + if err != nil { + return errors.Wrap(err, "failed to get configmap") + } + if cm.Labels != nil && cm.Labels["kots.io/processed"] == "true" { + return nil + } + if time.Sleep(1 * time.Second); time.Since(start) > 15*time.Second { + return errors.New("timed out waiting for deployment to be processed") + } + } +} + +func getDeploymentName(appSlug string) string { + return fmt.Sprintf("kotsadm-%s-deployment", appSlug) +} diff --git a/pkg/upgradeservice/handlers/config.go b/pkg/upgradeservice/handlers/config.go new file mode 100644 index 0000000000..34482df890 --- /dev/null +++ b/pkg/upgradeservice/handlers/config.go @@ -0,0 +1,441 @@ +package handlers + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/gorilla/mux" + "github.com/pkg/errors" + downstreamtypes "github.com/replicatedhq/kots/pkg/api/downstream/types" + apptypes "github.com/replicatedhq/kots/pkg/app/types" + "github.com/replicatedhq/kots/pkg/config" + kotsconfig "github.com/replicatedhq/kots/pkg/config" + "github.com/replicatedhq/kots/pkg/kotsadmconfig" + configtypes "github.com/replicatedhq/kots/pkg/kotsadmconfig/types" + configvalidation "github.com/replicatedhq/kots/pkg/kotsadmconfig/validation" + "github.com/replicatedhq/kots/pkg/kotsutil" + "github.com/replicatedhq/kots/pkg/logger" + registrytypes "github.com/replicatedhq/kots/pkg/registry/types" + "github.com/replicatedhq/kots/pkg/render" + rendertypes "github.com/replicatedhq/kots/pkg/render/types" + "github.com/replicatedhq/kots/pkg/template" + upgradepreflight "github.com/replicatedhq/kots/pkg/upgradeservice/preflight" + "github.com/replicatedhq/kots/pkg/util" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/multitype" +) + +type CurrentConfigResponse struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` + ConfigGroups []kotsv1beta1.ConfigGroup `json:"configGroups"` + ValidationErrors []configtypes.ConfigGroupValidationError `json:"validationErrors,omitempty"` +} + +type LiveConfigRequest struct { + ConfigGroups []kotsv1beta1.ConfigGroup `json:"configGroups"` +} + +type LiveConfigResponse struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` + ConfigGroups []kotsv1beta1.ConfigGroup `json:"configGroups"` + ValidationErrors []configtypes.ConfigGroupValidationError `json:"validationErrors,omitempty"` +} + +type SaveConfigRequest struct { + ConfigGroups []kotsv1beta1.ConfigGroup `json:"configGroups"` +} + +type SaveConfigResponse struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` + RequiredItems []string `json:"requiredItems,omitempty"` + ValidationErrors []configtypes.ConfigGroupValidationError `json:"validationErrors,omitempty"` +} + +type DownloadFileFromConfigResponse struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +func (h *Handler) CurrentConfig(w http.ResponseWriter, r *http.Request) { + response := CurrentConfigResponse{ + Success: false, + } + + params := GetContextParams(r) + + appLicense, err := kotsutil.LoadLicenseFromBytes([]byte(params.AppLicense)) + if err != nil { + response.Error = "failed to load license from bytes" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + + kotsKinds, err := kotsutil.LoadKotsKinds(params.AppArchive) + if err != nil { + response.Error = "failed to load kots kinds from path" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + + // get the non-rendered config from the upstream directory because we have to re-render it with the new values + nonRenderedConfig, err := kotsutil.FindConfigInPath(filepath.Join(params.AppArchive, "upstream")) + if err != nil { + response.Error = "failed to find non-rendered config" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + + registrySettings := registrytypes.RegistrySettings{ + Hostname: params.RegistryEndpoint, + Username: params.RegistryUsername, + Password: params.RegistryPassword, + Namespace: params.RegistryNamespace, + IsReadOnly: params.RegistryIsReadOnly, + } + + // get values from saved app version + configValues := map[string]template.ItemValue{} + + if kotsKinds.ConfigValues != nil { + for key, value := range kotsKinds.ConfigValues.Spec.Values { + generatedValue := template.ItemValue{ + Default: value.Default, + Value: value.Value, + Filename: value.Filename, + RepeatableItem: value.RepeatableItem, + } + configValues[key] = generatedValue + } + } + + versionInfo := template.VersionInfoFromInstallationSpec(params.NextSequence, params.AppIsAirgap, kotsKinds.Installation.Spec) + appInfo := template.ApplicationInfo{Slug: params.AppSlug} + renderedConfig, err := kotsconfig.TemplateConfigObjects(nonRenderedConfig, configValues, appLicense, &kotsKinds.KotsApplication, registrySettings, &versionInfo, &appInfo, kotsKinds.IdentityConfig, util.PodNamespace, false) + if err != nil { + logger.Error(err) + response.Error = "failed to render templates" + JSON(w, http.StatusInternalServerError, response) + return + } + + response.ConfigGroups = []kotsv1beta1.ConfigGroup{} + if renderedConfig != nil { + response.ConfigGroups = renderedConfig.Spec.Groups + } + + response.Success = true + JSON(w, http.StatusOK, response) +} + +func (h *Handler) LiveConfig(w http.ResponseWriter, r *http.Request) { + response := LiveConfigResponse{ + Success: false, + } + + params := GetContextParams(r) + + appLicense, err := kotsutil.LoadLicenseFromBytes([]byte(params.AppLicense)) + if err != nil { + response.Error = "failed to load license from bytes" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + + request := LiveConfigRequest{} + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + logger.Error(err) + response.Error = "failed to decode request body" + JSON(w, http.StatusBadRequest, response) + return + } + + kotsKinds, err := kotsutil.LoadKotsKinds(params.AppArchive) + if err != nil { + response.Error = "failed to load kots kinds from path" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + + // get the non-rendered config from the upstream directory because we have to re-render it with the new values + nonRenderedConfig, err := kotsutil.FindConfigInPath(filepath.Join(params.AppArchive, "upstream")) + if err != nil { + response.Error = "failed to find non-rendered config" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + + registrySettings := registrytypes.RegistrySettings{ + Hostname: params.RegistryEndpoint, + Username: params.RegistryUsername, + Password: params.RegistryPassword, + Namespace: params.RegistryNamespace, + IsReadOnly: params.RegistryIsReadOnly, + } + + // sequence +1 because the sequence will be incremented on save (and we want the preview to be accurate) + configValues := configValuesFromConfigGroups(request.ConfigGroups) + versionInfo := template.VersionInfoFromInstallationSpec(params.NextSequence, params.AppIsAirgap, kotsKinds.Installation.Spec) + appInfo := template.ApplicationInfo{Slug: params.AppSlug} + + renderedConfig, err := kotsconfig.TemplateConfigObjects(nonRenderedConfig, configValues, appLicense, &kotsKinds.KotsApplication, registrySettings, &versionInfo, &appInfo, kotsKinds.IdentityConfig, util.PodNamespace, false) + if err != nil { + response.Error = "failed to render templates" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + + response.ConfigGroups = []kotsv1beta1.ConfigGroup{} + if renderedConfig != nil { + validationErrors, err := configvalidation.ValidateConfigSpec(renderedConfig.Spec) + if err != nil { + response.Error = "failed to validate config spec" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + + response.ConfigGroups = renderedConfig.Spec.Groups + if len(validationErrors) > 0 { + response.ValidationErrors = validationErrors + logger.Warnf("Validation errors found for config spec: %v", validationErrors) + } + } + + response.Success = true + JSON(w, http.StatusOK, response) +} + +func configValuesFromConfigGroups(configGroups []kotsv1beta1.ConfigGroup) map[string]template.ItemValue { + configValues := map[string]template.ItemValue{} + + for _, group := range configGroups { + for _, item := range group.Items { + // collect all repeatable items + // Future Note: This could be refactored to use CountByGroup as the control. Front end provides the exact CountByGroup it wants, back end takes care of ValuesByGroup entries. + // this way the front end doesn't have to add anything to ValuesByGroup, it just sets values there. + if item.Repeatable { + for valuesByGroupName, groupValues := range item.ValuesByGroup { + config.CreateVariadicValues(&item, valuesByGroupName) + + for fieldName, subItem := range groupValues { + itemValue := template.ItemValue{ + Value: subItem, + RepeatableItem: item.Name, + } + if item.Filename != "" { + itemValue.Filename = fieldName + } + configValues[fieldName] = itemValue + } + } + continue + } + + generatedValue := template.ItemValue{} + if item.Value.Type == multitype.String { + generatedValue.Value = item.Value.StrVal + } else { + generatedValue.Value = item.Value.BoolVal + } + if item.Default.Type == multitype.String { + generatedValue.Default = item.Default.StrVal + } else { + generatedValue.Default = item.Default.BoolVal + } + if item.Type == "file" { + generatedValue.Filename = item.Filename + } + configValues[item.Name] = generatedValue + } + } + + return configValues +} + +func (h *Handler) SaveConfig(w http.ResponseWriter, r *http.Request) { + response := SaveConfigResponse{ + Success: false, + } + + params := GetContextParams(r) + + request := SaveConfigRequest{} + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + logger.Error(err) + response.Error = "failed to decode request body" + JSON(w, http.StatusBadRequest, response) + return + } + + validationErrors, err := configvalidation.ValidateConfigSpec(kotsv1beta1.ConfigSpec{Groups: request.ConfigGroups}) + if err != nil { + response.Error = "failed to validate config spec." + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + + if len(validationErrors) > 0 { + response.Error = "invalid config values" + response.ValidationErrors = validationErrors + logger.Errorf("%v, validation errors: %+v", response.Error, validationErrors) + JSON(w, http.StatusBadRequest, response) + return + } + + requiredItems, requiredItemsTitles := kotsadmconfig.GetMissingRequiredConfig(request.ConfigGroups) + if len(requiredItems) > 0 { + response.RequiredItems = requiredItems + response.Error = fmt.Sprintf("The following fields are required: %s", strings.Join(requiredItemsTitles, ", ")) + logger.Errorf("%v, required items: %+v", response.Error, requiredItems) + JSON(w, http.StatusBadRequest, response) + return + } + + registrySettings := registrytypes.RegistrySettings{ + Hostname: params.RegistryEndpoint, + Username: params.RegistryUsername, + Password: params.RegistryPassword, + Namespace: params.RegistryNamespace, + IsReadOnly: params.RegistryIsReadOnly, + } + + app := &apptypes.App{ + ID: params.AppID, + Slug: params.AppSlug, + IsAirgap: params.AppIsAirgap, + IsGitOps: params.AppIsGitOps, + } + + kotsKinds, err := kotsutil.LoadKotsKinds(params.AppArchive) + if err != nil { + response.Error = "failed to load kots kinds from path" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + + if kotsKinds.ConfigValues == nil { + err = errors.New("config values not found") + response.Error = err.Error() + logger.Error(err) + JSON(w, http.StatusInternalServerError, response) + return + } + + values := kotsKinds.ConfigValues.Spec.Values + kotsKinds.ConfigValues.Spec.Values = kotsadmconfig.UpdateAppConfigValues(values, request.ConfigGroups) + + configValuesSpec, err := kotsKinds.Marshal("kots.io", "v1beta1", "ConfigValues") + if err != nil { + response.Error = "failed to marshal config values" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + + if err := os.WriteFile(filepath.Join(params.AppArchive, "upstream", "userdata", "config.yaml"), []byte(configValuesSpec), 0644); err != nil { + response.Error = "failed to write config values" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + + err = render.RenderDir(rendertypes.RenderDirOptions{ + ArchiveDir: params.AppArchive, + App: app, + Downstreams: []downstreamtypes.Downstream{{Name: "this-cluster"}}, + RegistrySettings: registrySettings, + Sequence: params.NextSequence, + ReportingInfo: params.ReportingInfo, + }) + if err != nil { + cause := errors.Cause(err) + if _, ok := cause.(util.ActionableError); ok { + response.Error = err.Error() + JSON(w, http.StatusInternalServerError, response) + return + } else { + response.Error = "failed to render templates" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + } + + if err := upgradepreflight.Run(params); err != nil { + response.Error = "failed to run preflights" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + + response.Success = true + JSON(w, http.StatusOK, response) +} + +func (h *Handler) DownloadFileFromConfig(w http.ResponseWriter, r *http.Request) { + downloadFileFromConfigResponse := DownloadFileFromConfigResponse{ + Success: false, + } + + params := GetContextParams(r) + + filename := mux.Vars(r)["filename"] + if filename == "" { + logger.Error(errors.New("filename parameter is empty")) + downloadFileFromConfigResponse.Error = "failed to parse filename, parameter was empty" + JSON(w, http.StatusInternalServerError, downloadFileFromConfigResponse) + return + } + + kotsKinds, err := kotsutil.LoadKotsKinds(params.AppArchive) + if err != nil { + downloadFileFromConfigResponse.Error = "failed to load kots kinds from path" + logger.Error(errors.Wrap(err, downloadFileFromConfigResponse.Error)) + JSON(w, http.StatusInternalServerError, downloadFileFromConfigResponse) + return + } + + var configValue *string + for _, v := range kotsKinds.ConfigValues.Spec.Values { + if v.Filename == filename { + configValue = &v.Value + break + } + } + if configValue == nil { + logger.Error(errors.New("could not find requested file")) + downloadFileFromConfigResponse.Error = "could not find requested file" + JSON(w, http.StatusInternalServerError, downloadFileFromConfigResponse) + return + } + + decoded, err := base64.StdEncoding.DecodeString(*configValue) + if err != nil { + logger.Error(err) + downloadFileFromConfigResponse.Error = "failed to decode config value" + JSON(w, http.StatusInternalServerError, downloadFileFromConfigResponse) + } + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + w.Header().Set("Content-Length", strconv.Itoa(len(decoded))) + w.WriteHeader(http.StatusOK) + w.Write(decoded) +} diff --git a/pkg/upgradeservice/handlers/deploy.go b/pkg/upgradeservice/handlers/deploy.go new file mode 100644 index 0000000000..2a12502833 --- /dev/null +++ b/pkg/upgradeservice/handlers/deploy.go @@ -0,0 +1,89 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/kotsutil" + "github.com/replicatedhq/kots/pkg/logger" + registrytypes "github.com/replicatedhq/kots/pkg/registry/types" + "github.com/replicatedhq/kots/pkg/upgradeservice/deploy" +) + +type DeployRequest struct { + IsSkipPreflights bool `json:"isSkipPreflights"` + ContinueWithFailedPreflights bool `json:"continueWithFailedPreflights"` +} + +type DeployResponse struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +func (h *Handler) Deploy(w http.ResponseWriter, r *http.Request) { + response := DeployResponse{ + Success: false, + } + + params := GetContextParams(r) + + request := DeployRequest{} + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + response.Error = "failed to decode request body" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusBadRequest, response) + return + } + + kotsKinds, err := kotsutil.LoadKotsKinds(params.AppArchive) + if err != nil { + response.Error = "failed to load kots kinds from path" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + + registrySettings := registrytypes.RegistrySettings{ + Hostname: params.RegistryEndpoint, + Username: params.RegistryUsername, + Password: params.RegistryPassword, + Namespace: params.RegistryNamespace, + IsReadOnly: params.RegistryIsReadOnly, + } + + canDeploy, reason, err := deploy.CanDeploy(deploy.CanDeployOptions{ + Params: params, + KotsKinds: kotsKinds, + RegistrySettings: registrySettings, + }) + if err != nil { + response.Error = "failed to check if app can be deployed" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + if !canDeploy { + response.Error = reason + logger.Error(errors.New(response.Error)) + JSON(w, http.StatusBadRequest, response) + return + } + + if err := deploy.Deploy(deploy.DeployOptions{ + Ctx: r.Context(), + IsSkipPreflights: request.IsSkipPreflights, + ContinueWithFailedPreflights: request.ContinueWithFailedPreflights, + Params: params, + KotsKinds: kotsKinds, + RegistrySettings: registrySettings, + }); err != nil { + response.Error = "failed to deploy app" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + + response.Success = true + JSON(w, http.StatusOK, response) +} diff --git a/pkg/upgradeservice/handlers/handlers.go b/pkg/upgradeservice/handlers/handlers.go new file mode 100644 index 0000000000..7c2dc3782b --- /dev/null +++ b/pkg/upgradeservice/handlers/handlers.go @@ -0,0 +1,56 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/gorilla/mux" + "github.com/replicatedhq/kots/pkg/logger" + kotsscheme "github.com/replicatedhq/kotskinds/client/kotsclientset/scheme" + troubleshootscheme "github.com/replicatedhq/troubleshoot/pkg/client/troubleshootclientset/scheme" + veleroscheme "github.com/vmware-tanzu/velero/pkg/generated/clientset/versioned/scheme" + "k8s.io/client-go/kubernetes/scheme" +) + +var _ UpgradeServiceHandler = (*Handler)(nil) + +type Handler struct { +} + +func init() { + kotsscheme.AddToScheme(scheme.Scheme) + troubleshootscheme.AddToScheme(scheme.Scheme) + veleroscheme.AddToScheme(scheme.Scheme) +} + +func RegisterAPIRoutes(r *mux.Router, handler UpgradeServiceHandler) { + // CAUTION: modifying this prefix WILL break backwards compatibility + subRouter := r.PathPrefix("/api/v1/upgrade-service/app/{appSlug}").Subrouter() + subRouter.Use(LoggingMiddleware, AppSlugMiddleware) + + subRouter.Path("").Methods("GET").HandlerFunc(handler.Info) + subRouter.Path("/ping").Methods("GET").HandlerFunc(handler.Ping) + + subRouter.Path("/config").Methods("GET").HandlerFunc(handler.CurrentConfig) + subRouter.Path("/liveconfig").Methods("POST").HandlerFunc(handler.LiveConfig) + subRouter.Path("/config").Methods("PUT").HandlerFunc(handler.SaveConfig) + subRouter.Path("/config/{filename}/download").Methods("GET").HandlerFunc(handler.DownloadFileFromConfig) + + subRouter.Path("/preflight/run").Methods("POST").HandlerFunc(handler.StartPreflightChecks) + subRouter.Path("/preflight/result").Methods("GET").HandlerFunc(handler.GetPreflightResult) + + subRouter.Path("/deploy").Methods("POST").HandlerFunc(handler.Deploy) +} + +func JSON(w http.ResponseWriter, code int, payload interface{}) { + response, err := json.Marshal(payload) + if err != nil { + logger.Error(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + w.Write(response) +} diff --git a/pkg/upgradeservice/handlers/info.go b/pkg/upgradeservice/handlers/info.go new file mode 100644 index 0000000000..ccdb94c310 --- /dev/null +++ b/pkg/upgradeservice/handlers/info.go @@ -0,0 +1,38 @@ +package handlers + +import ( + "net/http" + + "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/kotsutil" + "github.com/replicatedhq/kots/pkg/logger" +) + +type InfoResponse struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` + HasPreflight bool `json:"hasPreflight"` + IsConfigurable bool `json:"isConfigurable"` +} + +func (h *Handler) Info(w http.ResponseWriter, r *http.Request) { + response := InfoResponse{ + Success: false, + } + + params := GetContextParams(r) + + kotsKinds, err := kotsutil.LoadKotsKinds(params.AppArchive) + if err != nil { + response.Error = "failed to load kots kinds from path" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + + response.Success = true + response.HasPreflight = kotsKinds.HasPreflights() + response.IsConfigurable = kotsKinds.IsConfigurable() + + JSON(w, http.StatusOK, response) +} diff --git a/pkg/upgradeservice/handlers/interface.go b/pkg/upgradeservice/handlers/interface.go new file mode 100644 index 0000000000..ccb467a5cb --- /dev/null +++ b/pkg/upgradeservice/handlers/interface.go @@ -0,0 +1,18 @@ +package handlers + +import "net/http" + +type UpgradeServiceHandler interface { + Info(w http.ResponseWriter, r *http.Request) + Ping(w http.ResponseWriter, r *http.Request) + + CurrentConfig(w http.ResponseWriter, r *http.Request) + LiveConfig(w http.ResponseWriter, r *http.Request) + SaveConfig(w http.ResponseWriter, r *http.Request) + DownloadFileFromConfig(w http.ResponseWriter, r *http.Request) + + StartPreflightChecks(w http.ResponseWriter, r *http.Request) + GetPreflightResult(w http.ResponseWriter, r *http.Request) + + Deploy(w http.ResponseWriter, r *http.Request) +} diff --git a/pkg/upgradeservice/handlers/middleware.go b/pkg/upgradeservice/handlers/middleware.go new file mode 100644 index 0000000000..523e609867 --- /dev/null +++ b/pkg/upgradeservice/handlers/middleware.go @@ -0,0 +1,82 @@ +package handlers + +import ( + "context" + "net/http" + "os" + "time" + + "github.com/gorilla/mux" + "github.com/replicatedhq/kots/pkg/logger" + "github.com/replicatedhq/kots/pkg/upgradeservice/types" +) + +type paramsKey struct{} + +func SetContextParams(r *http.Request, params types.UpgradeServiceParams) *http.Request { + return r.WithContext(context.WithValue(r.Context(), paramsKey{}, params)) +} + +func GetContextParams(r *http.Request) types.UpgradeServiceParams { + val := r.Context().Value(paramsKey{}) + params, _ := val.(types.UpgradeServiceParams) + return params +} + +func ParamsMiddleware(params types.UpgradeServiceParams) mux.MiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = SetContextParams(r, params) + next.ServeHTTP(w, r) + }) + } +} + +type loggingResponseWriter struct { + http.ResponseWriter + StatusCode int +} + +func NewLoggingResponseWriter(w http.ResponseWriter) *loggingResponseWriter { + return &loggingResponseWriter{w, http.StatusOK} +} + +func (lrw *loggingResponseWriter) WriteHeader(code int) { + lrw.StatusCode = code + lrw.ResponseWriter.WriteHeader(code) +} + +func LoggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + + lrw := NewLoggingResponseWriter(w) + next.ServeHTTP(lrw, r) + + if os.Getenv("DEBUG") != "true" && lrw.StatusCode < http.StatusBadRequest { + return + } + + logger.Infof( + "method=%s status=%d duration=%s request=%s", + r.Method, + lrw.StatusCode, + time.Since(startTime).String(), + r.RequestURI, + ) + }) +} + +func AppSlugMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + params := GetContextParams(r) + appSlug := mux.Vars(r)["appSlug"] + + if params.AppSlug != appSlug { + JSON(w, http.StatusForbidden, "app slug mismatch") + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/pkg/upgradeservice/handlers/ping.go b/pkg/upgradeservice/handlers/ping.go new file mode 100644 index 0000000000..f2c193804c --- /dev/null +++ b/pkg/upgradeservice/handlers/ping.go @@ -0,0 +1,15 @@ +package handlers + +import ( + "net/http" +) + +type PingResponse struct { + Ping string `json:"ping"` +} + +func (h *Handler) Ping(w http.ResponseWriter, r *http.Request) { + pingResponse := PingResponse{} + pingResponse.Ping = "pong" + JSON(w, http.StatusOK, pingResponse) +} diff --git a/pkg/upgradeservice/handlers/preflight.go b/pkg/upgradeservice/handlers/preflight.go new file mode 100644 index 0000000000..db964b74e1 --- /dev/null +++ b/pkg/upgradeservice/handlers/preflight.go @@ -0,0 +1,71 @@ +package handlers + +import ( + "net/http" + + "github.com/gorilla/mux" + "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/logger" + preflighttypes "github.com/replicatedhq/kots/pkg/preflight/types" + upgradepreflight "github.com/replicatedhq/kots/pkg/upgradeservice/preflight" +) + +type GetPreflightResultResponse struct { + PreflightProgress string `json:"preflightProgress,omitempty"` + PreflightResult preflighttypes.PreflightResult `json:"preflightResult"` +} + +func (h *Handler) StartPreflightChecks(w http.ResponseWriter, r *http.Request) { + params := GetContextParams(r) + appSlug := mux.Vars(r)["appSlug"] + + if params.AppSlug != appSlug { + logger.Error(errors.Errorf("app slug in path %s does not match app slug in context %s", appSlug, params.AppSlug)) + w.WriteHeader(http.StatusForbidden) + return + } + + if err := upgradepreflight.ResetPreflightData(); err != nil { + logger.Error(errors.Wrap(err, "failed to reset preflight data")) + w.WriteHeader(http.StatusInternalServerError) + return + } + + go func() { + if err := upgradepreflight.Run(params); err != nil { + logger.Error(errors.Wrap(err, "failed to run preflights")) + return + } + }() + + JSON(w, http.StatusOK, struct{}{}) +} + +func (h *Handler) GetPreflightResult(w http.ResponseWriter, r *http.Request) { + params := GetContextParams(r) + appSlug := mux.Vars(r)["appSlug"] + + if params.AppSlug != appSlug { + logger.Error(errors.Errorf("app slug in path %s does not match app slug in context %s", appSlug, params.AppSlug)) + w.WriteHeader(http.StatusForbidden) + return + } + + preflightData, err := upgradepreflight.GetPreflightData() + if err != nil { + logger.Error(errors.Wrap(err, "failed to get preflight data")) + w.WriteHeader(http.StatusInternalServerError) + return + } + + var preflightResult preflighttypes.PreflightResult + if preflightData.Result != nil { + preflightResult = *preflightData.Result + } + + response := GetPreflightResultResponse{ + PreflightResult: preflightResult, + PreflightProgress: preflightData.Progress, + } + JSON(w, 200, response) +} diff --git a/pkg/upgradeservice/handlers/spa.go b/pkg/upgradeservice/handlers/spa.go new file mode 100644 index 0000000000..784a354fb3 --- /dev/null +++ b/pkg/upgradeservice/handlers/spa.go @@ -0,0 +1,80 @@ +package handlers + +import ( + "bytes" + "fmt" + "io/fs" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/gorilla/mux" + "github.com/replicatedhq/kots/web" +) + +// SPAHandler implements the http.Handler interface, so we can use it +// to respond to HTTP requests. The path to the static directory and +// path to the index file within that static directory are used to +// serve the SPA in the given static directory. +type SPAHandler struct { +} + +// ServeHTTP inspects the URL path to locate a file within the static dir +// on the SPA handler. If a file is found, it will be served. If not, the +// file located at the index path on the SPA handler will be served. This +// is suitable behavior for serving an SPA (single page application). +func (h SPAHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // because the docs say to not modify request, and we need to, so lets clone + rr := r.Clone(r.Context()) + rr.URL.Path = strings.TrimPrefix(rr.URL.Path, upgradeServicePrefix(rr)) + + // get the absolute path to prevent directory traversal + path, err := filepath.Abs(rr.URL.Path) + if err != nil { + // if we failed to get the absolute path respond with a 400 bad request + // and stop + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // prepend the path with the path to the static directory + fsys, err := fs.Sub(web.Content, "dist") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // check whether a file exists at the given path + _, err = fs.Stat(fsys, filepath.Join(".", path)) // because ... fs.Sub seems to require this + if os.IsNotExist(err) || path == "/" { + // serve index.html + content, err := web.Content.ReadFile("dist/index.html") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // set the js bundle path first + content = bytes.ReplaceAll(content, []byte(`src="/`), []byte(fmt.Sprintf(`src="%s/`, upgradeServicePrefix(rr)))) + + w.Header().Set("Content-Type", "text/html") + w.Header().Set("Content-Length", strconv.Itoa(len(content))) + w.Write(content) + return + } else if err != nil { + // if we got an error (that wasn't that the file doesn't exist) stating the + // file, return a 500 internal server error and stop + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // otherwise, use http.FileServer to serve the static dir + // TODO: set public path to go through the upgrade service + http.FileServer(http.FS(fsys)).ServeHTTP(w, rr) +} + +func upgradeServicePrefix(r *http.Request) string { + return fmt.Sprintf("/upgrade-service/app/%s", mux.Vars(r)["appSlug"]) +} diff --git a/pkg/upgradeservice/handlers/static.go b/pkg/upgradeservice/handlers/static.go new file mode 100644 index 0000000000..8a908e463a --- /dev/null +++ b/pkg/upgradeservice/handlers/static.go @@ -0,0 +1,13 @@ +package handlers + +import ( + "net/http" +) + +type StatusNotFoundHandler struct { +} + +func (h StatusNotFoundHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + http.Error(w, "", http.StatusNotFound) + return +} diff --git a/pkg/upgradeservice/preflight/preflight.go b/pkg/upgradeservice/preflight/preflight.go new file mode 100644 index 0000000000..cc73cb75c7 --- /dev/null +++ b/pkg/upgradeservice/preflight/preflight.go @@ -0,0 +1,239 @@ +package preflight + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/kotsutil" + "github.com/replicatedhq/kots/pkg/logger" + preflightpkg "github.com/replicatedhq/kots/pkg/preflight" + "github.com/replicatedhq/kots/pkg/preflight/types" + "github.com/replicatedhq/kots/pkg/registry" + registrytypes "github.com/replicatedhq/kots/pkg/registry/types" + "github.com/replicatedhq/kots/pkg/render" + rendertypes "github.com/replicatedhq/kots/pkg/render/types" + upgradeservicetypes "github.com/replicatedhq/kots/pkg/upgradeservice/types" + "github.com/replicatedhq/kots/pkg/util" + troubleshootanalyze "github.com/replicatedhq/troubleshoot/pkg/analyze" + troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" + troubleshootpreflight "github.com/replicatedhq/troubleshoot/pkg/preflight" + "go.uber.org/zap" +) + +type PreflightData struct { + Progress string `json:"progress,omitempty"` + Result *types.PreflightResult `json:"result"` +} + +var PreflightDataFile string + +func Init() error { + tmpDir, err := os.MkdirTemp("", "preflights") + if err != nil { + return errors.Wrap(err, "failed to create temp dir") + } + PreflightDataFile = filepath.Join(tmpDir, "preflights.json") + return nil +} + +func Run(params upgradeservicetypes.UpgradeServiceParams) error { + kotsKinds, err := kotsutil.LoadKotsKinds(params.AppArchive) + if err != nil { + return errors.Wrap(err, "failed to load rendered kots kinds") + } + + tsKinds, err := kotsutil.LoadTSKindsFromPath(filepath.Join(params.AppArchive, "rendered")) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("failed to load troubleshoot kinds from path: %s", filepath.Join(params.AppArchive, "rendered"))) + } + + registrySettings := registrytypes.RegistrySettings{ + Hostname: params.RegistryEndpoint, + Username: params.RegistryUsername, + Password: params.RegistryPassword, + Namespace: params.RegistryNamespace, + IsReadOnly: params.RegistryIsReadOnly, + } + + var preflight *troubleshootv1beta2.Preflight + if tsKinds.PreflightsV1Beta2 != nil { + for _, v := range tsKinds.PreflightsV1Beta2 { + preflight = troubleshootpreflight.ConcatPreflightSpec(preflight, &v) + } + } else if kotsKinds.Preflight != nil { + renderedMarshalledPreflights, err := kotsKinds.Marshal("troubleshoot.replicated.com", "v1beta1", "Preflight") + if err != nil { + return errors.Wrap(err, "failed to marshal rendered preflight") + } + renderedPreflight, err := render.RenderFile(rendertypes.RenderFileOptions{ + KotsKinds: kotsKinds, + RegistrySettings: registrySettings, + AppSlug: params.AppSlug, + Sequence: params.NextSequence, + IsAirgap: params.AppIsAirgap, + Namespace: util.PodNamespace, + InputContent: []byte(renderedMarshalledPreflights), + }) + if err != nil { + return errors.Wrap(err, "failed to render preflights") + } + preflight, err = kotsutil.LoadPreflightFromContents(renderedPreflight) + if err != nil { + return errors.Wrap(err, "failed to load rendered preflight") + } + } + + if preflight == nil { + logger.Info("no preflight spec found, not running preflights") + return nil + } + + preflightpkg.InjectDefaultPreflights(preflight, kotsKinds, registrySettings) + + numAnalyzers := 0 + for _, analyzer := range preflight.Spec.Analyzers { + exclude := troubleshootanalyze.GetExcludeFlag(analyzer).BoolOrDefaultFalse() + if !exclude { + numAnalyzers += 1 + } + } + if numAnalyzers == 0 { + logger.Info("no analyzers found, not running preflights") + return nil + } + + var preflightErr error + defer func() { + if preflightErr != nil { + preflightResults := &types.PreflightResults{ + Errors: []*types.PreflightError{ + &types.PreflightError{ + Error: preflightErr.Error(), + IsRBAC: false, + }, + }, + } + if err := setPreflightResults(params.AppSlug, preflightResults); err != nil { + logger.Error(errors.Wrap(err, "failed to set preflight results")) + return + } + } + }() + + collectors, err := registry.UpdateCollectorSpecsWithRegistryData(preflight.Spec.Collectors, registrySettings, kotsKinds.Installation, kotsKinds.License, &kotsKinds.KotsApplication) + if err != nil { + preflightErr = errors.Wrap(err, "failed to rewrite images in preflight") + return preflightErr + } + preflight.Spec.Collectors = collectors + + go func() { + logger.Info("preflight checks beginning", + zap.String("appID", params.AppID), + zap.Int64("sequence", params.NextSequence)) + + setResults := func(results *types.PreflightResults) error { + return setPreflightResults(params.AppSlug, results) + } + + _, err := preflightpkg.Execute(preflight, false, setPreflightProgress, setResults) + if err != nil { + logger.Error(errors.Wrap(err, "failed to run preflight checks")) + return + } + }() + + return nil +} + +func setPreflightResults(appSlug string, results *types.PreflightResults) error { + resultsBytes, err := json.Marshal(results) + if err != nil { + return errors.Wrap(err, "failed to marshal preflight results") + } + createdAt := time.Now() + preflightData := &PreflightData{ + Result: &types.PreflightResult{ + Result: string(resultsBytes), + CreatedAt: &createdAt, + AppSlug: appSlug, + ClusterSlug: "this-cluster", + Skipped: false, + HasFailingStrictPreflights: hasFailingStrictPreflights(results), + }, + Progress: "", // clear the progress once the results are set + } + if err := setPreflightData(preflightData); err != nil { + return errors.Wrap(err, "failed to set preflight results") + } + return nil +} + +func hasFailingStrictPreflights(results *types.PreflightResults) bool { + // convert to troubleshoot type so we can use the existing function + uploadResults := &troubleshootpreflight.UploadPreflightResults{} + uploadResults.Results = results.Results + for _, e := range results.Errors { + uploadResults.Errors = append(uploadResults.Errors, &troubleshootpreflight.UploadPreflightError{ + Error: e.Error, + }) + } + return troubleshootpreflight.HasStrictAnalyzersFailed(uploadResults) +} + +func setPreflightProgress(progress map[string]interface{}) error { + preflightData, err := GetPreflightData() + if err != nil { + return errors.Wrap(err, "failed to get preflight data") + } + progressBytes, err := json.Marshal(progress) + if err != nil { + return errors.Wrap(err, "failed to marshal preflight progress") + } + preflightData.Progress = string(progressBytes) + if err := setPreflightData(preflightData); err != nil { + return errors.Wrap(err, "failed to set preflight progress") + } + return nil +} + +func GetPreflightData() (*PreflightData, error) { + var preflightData *PreflightData + if _, err := os.Stat(PreflightDataFile); err != nil { + if !os.IsNotExist(err) { + return nil, errors.Wrap(err, "failed to stat existing preflight data") + } + preflightData = &PreflightData{} + } else { + existingBytes, err := os.ReadFile(PreflightDataFile) + if err != nil { + return nil, errors.Wrap(err, "failed to read existing preflight data") + } + if err := json.Unmarshal(existingBytes, &preflightData); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal existing preflight data") + } + } + return preflightData, nil +} + +func setPreflightData(preflightData *PreflightData) error { + b, err := json.Marshal(preflightData) + if err != nil { + return errors.Wrap(err, "failed to marshal preflight data") + } + if err := os.WriteFile(PreflightDataFile, b, 0644); err != nil { + return errors.Wrap(err, "failed to write preflight data") + } + return nil +} + +func ResetPreflightData() error { + if err := os.RemoveAll(PreflightDataFile); err != nil { + return errors.Wrap(err, "failed to remove preflight data") + } + return nil +} diff --git a/pkg/upgradeservice/process.go b/pkg/upgradeservice/process.go new file mode 100644 index 0000000000..0ec9a7ae77 --- /dev/null +++ b/pkg/upgradeservice/process.go @@ -0,0 +1,165 @@ +package upgradeservice + +import ( + "fmt" + "io" + "net/http" + "net/http/httputil" + "net/url" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/gorilla/mux" + "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/logger" + "github.com/replicatedhq/kots/pkg/upgradeservice/types" + "gopkg.in/yaml.v3" +) + +type UpgradeService struct { + cmd *exec.Cmd + port string +} + +// map of app slug to upgrade service +var upgradeServiceMap = map[string]*UpgradeService{} +var upgradeServiceMtx = &sync.Mutex{} + +// Start spins up an upgrade service for an app in the background on a random port and waits for it to be ready. +// If an upgrade service is already running for the app, it will be stopped and a new one will be started. +func Start(params types.UpgradeServiceParams) (finalError error) { + svc, err := start(params) + if err != nil { + return errors.Wrap(err, "failed to create new upgrade service") + } + if err := svc.waitForReady(params.AppSlug); err != nil { + return errors.Wrap(err, "failed to wait for upgrade service to become ready") + } + return nil +} + +// Stop stops the upgrade service for an app. +func Stop(appSlug string) { + upgradeServiceMtx.Lock() + defer upgradeServiceMtx.Unlock() + + svc, _ := upgradeServiceMap[appSlug] + if svc != nil { + svc.stop() + } + delete(upgradeServiceMap, appSlug) +} + +// Proxy proxies the request to the app's upgrade service. +func Proxy(w http.ResponseWriter, r *http.Request) { + appSlug := mux.Vars(r)["appSlug"] + if appSlug == "" { + logger.Error(errors.New("upgrade service requires app slug in path")) + w.WriteHeader(http.StatusBadRequest) + return + } + + svc, ok := upgradeServiceMap[appSlug] + if !ok { + logger.Error(errors.Errorf("upgrade service not found for app %s", appSlug)) + w.WriteHeader(http.StatusServiceUnavailable) + return + } + + if !svc.isRunning() { + logger.Error(errors.Errorf("upgrade service is not running for app %s", appSlug)) + w.WriteHeader(http.StatusServiceUnavailable) + return + } + + remote, err := url.Parse(fmt.Sprintf("http://localhost:%s", svc.port)) + if err != nil { + logger.Error(errors.Wrap(err, "failed to parse upgrade service url")) + w.WriteHeader(http.StatusInternalServerError) + return + } + + proxy := httputil.NewSingleHostReverseProxy(remote) + proxy.ServeHTTP(w, r) +} + +func start(params types.UpgradeServiceParams) (*UpgradeService, error) { + upgradeServiceMtx.Lock() + defer upgradeServiceMtx.Unlock() + + // stop the current service + currSvc, _ := upgradeServiceMap[params.AppSlug] + if currSvc != nil { + currSvc.stop() + } + + paramsYAML, err := yaml.Marshal(params) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal params") + } + + cmd := exec.Command(params.UpdateKOTSBin, "upgrade-service", "start", "-") + cmd.Stdin = strings.NewReader(string(paramsYAML)) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return nil, errors.Wrap(err, "failed to start") + } + + // calling wait helps populate the process state and reap the zombie process + go cmd.Wait() + + // create a new service + newSvc := &UpgradeService{ + cmd: cmd, + port: params.Port, + } + upgradeServiceMap[params.AppSlug] = newSvc + + return newSvc, nil +} + +func (s *UpgradeService) stop() { + if !s.isRunning() { + return + } + logger.Infof("Stopping upgrade service on port %s", s.port) + if err := s.cmd.Process.Signal(os.Interrupt); err != nil { + logger.Errorf("Failed to stop upgrade service on port %s: %v", s.port, err) + } +} + +func (s *UpgradeService) isRunning() bool { + return s != nil && s.cmd != nil && s.cmd.ProcessState == nil +} + +func (s *UpgradeService) waitForReady(appSlug string) error { + var lasterr error + for { + time.Sleep(time.Second) + if s == nil || s.cmd == nil { + return errors.New("upgrade service not found") + } + if s.cmd.ProcessState != nil { + return errors.Errorf("upgrade service terminated. last error: %v", lasterr) + } + request, err := http.NewRequest("GET", fmt.Sprintf("http://localhost:%s/api/v1/upgrade-service/app/%s/ping", s.port, appSlug), nil) + if err != nil { + lasterr = errors.Wrap(err, "failed to create request") + continue + } + response, err := http.DefaultClient.Do(request) + if err != nil { + lasterr = errors.Wrap(err, "failed to do request") + continue + } + if response.StatusCode != http.StatusOK { + body, _ := io.ReadAll(response.Body) + return errors.Errorf("unexpected status code %d: %s", response.StatusCode, string(body)) + } + return nil + } +} diff --git a/pkg/upgradeservice/server.go b/pkg/upgradeservice/server.go new file mode 100644 index 0000000000..08899e6627 --- /dev/null +++ b/pkg/upgradeservice/server.go @@ -0,0 +1,95 @@ +package upgradeservice + +import ( + "context" + "fmt" + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" + "os/signal" + "path/filepath" + "time" + + "github.com/gorilla/mux" + "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/buildversion" + "github.com/replicatedhq/kots/pkg/upgradeservice/handlers" + upgradepreflight "github.com/replicatedhq/kots/pkg/upgradeservice/preflight" + "github.com/replicatedhq/kots/pkg/upgradeservice/types" +) + +func Serve(params types.UpgradeServiceParams) error { + fmt.Printf("Starting KOTS Upgrade Service version %s on port %s\n", buildversion.Version(), params.Port) + + // cleanup on shutdown + defer cleanup(params) + + if err := bootstrap(params); err != nil { + return errors.Wrap(err, "failed to bootstrap") + } + + if err := upgradepreflight.Init(); err != nil { + return errors.Wrap(err, "failed to init preflight") + } + + r := mux.NewRouter() + r.Use(handlers.ParamsMiddleware(params)) + + handler := &handlers.Handler{} + handlers.RegisterAPIRoutes(r, handler) + + /********************************************************************** + * Static routes + **********************************************************************/ + + if os.Getenv("DISABLE_SPA_SERVING") != "1" { // we don't serve this in the dev env + spa := handlers.SPAHandler{} + r.PathPrefix("/upgrade-service/app/{appSlug}").Handler(spa) + } else if os.Getenv("ENABLE_WEB_PROXY") == "1" { // for dev env + u, err := url.Parse("http://kotsadm-web:8080") + if err != nil { + return errors.Wrap(err, "failed to parse kotsadm-web url") + } + upstream := httputil.NewSingleHostReverseProxy(u) + webProxy := func(upstream *httputil.ReverseProxy) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + r.Header.Set("X-Forwarded-Host", r.Header.Get("Host")) + upstream.ServeHTTP(w, r) + } + }(upstream) + r.PathPrefix("/upgrade-service/app/{appSlug}").HandlerFunc(webProxy) + } + + srv := &http.Server{ + Handler: r, + Addr: fmt.Sprintf(":%s", params.Port), + } + + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("listen: %s\n", err) + } + }() + + // wait for interrupt signal to gracefully shut down the server and cleanup + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt) + <-c + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + return errors.Wrap(err, "failed to shutdown server") + } + + return nil +} + +func cleanup(params types.UpgradeServiceParams) { + os.RemoveAll(params.AppArchive) + os.RemoveAll(params.UpdateKOTSBin) + os.RemoveAll(filepath.Dir(upgradepreflight.PreflightDataFile)) +} diff --git a/pkg/upgradeservice/task/task.go b/pkg/upgradeservice/task/task.go new file mode 100644 index 0000000000..cd0c66646e --- /dev/null +++ b/pkg/upgradeservice/task/task.go @@ -0,0 +1,46 @@ +package task + +import ( + "fmt" + + "github.com/replicatedhq/kots/pkg/tasks" +) + +type Status string + +// CAUTION: modifying existing statuses will break backwards compatibility +const ( + StatusStarting Status = "starting" + StatusUpgradingCluster Status = "upgrading-cluster" + StatusUpgradingApp Status = "upgrading-app" + StatusUpgradeFailed Status = "upgrade-failed" +) + +// CAUTION: modifying this task id will break backwards compatibility +func GetID(appSlug string) string { + return fmt.Sprintf("upgrade-service-%s", appSlug) +} + +func GetStatus(appSlug string) (string, string, error) { + return tasks.GetTaskStatus(GetID(appSlug)) +} + +func ClearStatus(appSlug string) error { + return tasks.ClearTaskStatus(GetID(appSlug)) +} + +func SetStatusStarting(appSlug string, msg string) error { + return tasks.SetTaskStatus(GetID(appSlug), msg, string(StatusStarting)) +} + +func SetStatusUpgradingCluster(appSlug string, msg string) error { + return tasks.SetTaskStatus(GetID(appSlug), msg, string(StatusUpgradingCluster)) +} + +func SetStatusUpgradingApp(appSlug string, msg string) error { + return tasks.SetTaskStatus(GetID(appSlug), msg, string(StatusUpgradingApp)) +} + +func SetStatusUpgradeFailed(appSlug string, msg string) error { + return tasks.SetTaskStatus(GetID(appSlug), msg, string(StatusUpgradeFailed)) +} diff --git a/pkg/upgradeservice/types/types.go b/pkg/upgradeservice/types/types.go new file mode 100644 index 0000000000..d5c2bdcda3 --- /dev/null +++ b/pkg/upgradeservice/types/types.go @@ -0,0 +1,38 @@ +package types + +import ( + reportingtypes "github.com/replicatedhq/kots/pkg/api/reporting/types" +) + +type UpgradeServiceParams struct { + Port string `yaml:"port"` + + AppID string `yaml:"appId"` + AppSlug string `yaml:"appSlug"` + AppName string `yaml:"appName"` + AppIsAirgap bool `yaml:"appIsAirgap"` + AppIsGitOps bool `yaml:"appIsGitOps"` + AppLicense string `yaml:"appLicense"` + AppArchive string `yaml:"appArchive"` + + Source string `yaml:"source"` + BaseSequence int64 `yaml:"baseSequence"` + NextSequence int64 `yaml:"nextSequence"` + + UpdateVersionLabel string `yaml:"updateVersionLabel"` + UpdateCursor string `yaml:"updateCursor"` + UpdateChannelID string `yaml:"updateChannelID"` + UpdateECVersion string `yaml:"updateECVersion"` + UpdateKOTSBin string `yaml:"updateKotsBin"` + UpdateAirgapBundle string `yaml:"updateAirgapBundle"` + + CurrentECVersion string `yaml:"currentECVersion"` + + RegistryEndpoint string `yaml:"registryEndpoint"` + RegistryUsername string `yaml:"registryUsername"` + RegistryPassword string `yaml:"registryPassword"` + RegistryNamespace string `yaml:"registryNamespace"` + RegistryIsReadOnly bool `yaml:"registryIsReadOnly"` + + ReportingInfo *reportingtypes.ReportingInfo `yaml:"reportingInfo"` +} diff --git a/pkg/upstream/peek.go b/pkg/upstream/peek.go index 708be962e5..908194e59e 100644 --- a/pkg/upstream/peek.go +++ b/pkg/upstream/peek.go @@ -18,7 +18,7 @@ func GetUpdatesUpstream(upstreamURI string, fetchOptions *types.FetchOptions) (* return nil, errors.Wrap(err, "parse request uri failed") } if u.Scheme == "replicated" { - return getUpdatesReplicated(u, fetchOptions) + return getUpdatesReplicated(fetchOptions) } return nil, errors.Errorf("unknown protocol scheme %q", u.Scheme) diff --git a/pkg/upstream/replicated.go b/pkg/upstream/replicated.go index f083699374..8569db4841 100644 --- a/pkg/upstream/replicated.go +++ b/pkg/upstream/replicated.go @@ -69,7 +69,7 @@ type ChannelRelease struct { ReleaseNotes string `json:"releaseNotes"` } -func getUpdatesReplicated(u *url.URL, fetchOptions *types.FetchOptions) (*types.UpdateCheckResult, error) { +func getUpdatesReplicated(fetchOptions *types.FetchOptions) (*types.UpdateCheckResult, error) { currentCursor := replicatedapp.ReplicatedCursor{ ChannelID: fetchOptions.CurrentChannelID, ChannelName: fetchOptions.CurrentChannelName, @@ -81,12 +81,7 @@ func getUpdatesReplicated(u *url.URL, fetchOptions *types.FetchOptions) (*types. return nil, errors.New("No license was provided") } - replicatedUpstream, err := replicatedapp.ParseReplicatedURL(u) - if err != nil { - return nil, errors.Wrap(err, "failed to parse replicated upstream") - } - - pendingReleases, updateCheckTime, err := listPendingChannelReleases(replicatedUpstream, fetchOptions.License, fetchOptions.LastUpdateCheckAt, currentCursor, fetchOptions.ChannelChanged, fetchOptions.ReportingInfo) + pendingReleases, updateCheckTime, err := listPendingChannelReleases(fetchOptions.License, fetchOptions.LastUpdateCheckAt, currentCursor, fetchOptions.ChannelChanged, fetchOptions.SortOrder, fetchOptions.ReportingInfo) if err != nil { return nil, errors.Wrap(err, "failed to list replicated app releases") } @@ -364,7 +359,7 @@ func downloadReplicatedApp(replicatedUpstream *replicatedapp.ReplicatedUpstream, defer getResp.Body.Close() if getResp.StatusCode >= 300 { - body, _ := ioutil.ReadAll(getResp.Body) + body, _ := io.ReadAll(getResp.Body) if len(body) > 0 { return nil, util.ActionableError{Message: string(body)} } @@ -446,7 +441,7 @@ func downloadReplicatedApp(replicatedUpstream *replicatedapp.ReplicatedUpstream, return &release, nil } -func listPendingChannelReleases(replicatedUpstream *replicatedapp.ReplicatedUpstream, license *kotsv1beta1.License, lastUpdateCheckAt *time.Time, currentCursor replicatedapp.ReplicatedCursor, channelChanged bool, reportingInfo *reportingtypes.ReportingInfo) ([]ChannelRelease, *time.Time, error) { +func listPendingChannelReleases(license *kotsv1beta1.License, lastUpdateCheckAt *time.Time, currentCursor replicatedapp.ReplicatedCursor, channelChanged bool, sortOrder string, reportingInfo *reportingtypes.ReportingInfo) ([]ChannelRelease, *time.Time, error) { u, err := url.Parse(license.Spec.Endpoint) if err != nil { return nil, nil, errors.Wrap(err, "failed to parse endpoint from license") @@ -471,6 +466,10 @@ func listPendingChannelReleases(replicatedUpstream *replicatedapp.ReplicatedUpst urlValues.Add("lastUpdateCheckAt", lastUpdateCheckAt.UTC().Format(time.RFC3339)) } + if sortOrder != "" { + urlValues.Add("sortOrder", sortOrder) + } + url := fmt.Sprintf("%s://%s/release/%s/pending?%s", u.Scheme, hostname, license.Spec.AppSlug, urlValues.Encode()) req, err := util.NewRequest("GET", url, nil) diff --git a/pkg/upstream/types/types.go b/pkg/upstream/types/types.go index a6d72d7657..126792a551 100644 --- a/pkg/upstream/types/types.go +++ b/pkg/upstream/types/types.go @@ -108,6 +108,7 @@ type FetchOptions struct { CurrentReplicatedChartNames []string CurrentEmbeddedClusterArtifacts *kotsv1beta1.EmbeddedClusterArtifacts ChannelChanged bool + SortOrder string AppSlug string AppSequence int64 AppVersionLabel string diff --git a/pkg/upstream/upgrade.go b/pkg/upstream/upgrade.go index 45c660930b..ed432ff343 100644 --- a/pkg/upstream/upgrade.go +++ b/pkg/upstream/upgrade.go @@ -225,7 +225,7 @@ func Upgrade(appSlug string, options UpgradeOptions) (*UpgradeResponse, error) { } func createPartFromFile(partWriter *multipart.Writer, path string, fileName string) error { - contents, err := archives.GetFileFromAirgap(fileName, path) + contents, err := archives.GetFileContentFromTGZArchive(fileName, path) if err != nil { return errors.Wrapf(err, "failed to get file %s from airgap", fileName) } diff --git a/pkg/util/util.go b/pkg/util/util.go index 5b03fce0c6..6fd7d111cb 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -177,6 +177,10 @@ func EmbeddedClusterVersion() string { return os.Getenv("EMBEDDED_CLUSTER_VERSION") } +func IsUpgradeService() bool { + return os.Getenv("IS_UPGRADE_SERVICE") == "true" +} + func HTTPProxy() string { return os.Getenv("HTTP_PROXY") } diff --git a/pkg/version/version.go b/pkg/version/version.go index eedefda8c4..6acf060870 100644 --- a/pkg/version/version.go +++ b/pkg/version/version.go @@ -7,7 +7,6 @@ import ( "github.com/pkg/errors" "github.com/replicatedhq/kots/pkg/api/version/types" - "github.com/replicatedhq/kots/pkg/gitops" "github.com/replicatedhq/kots/pkg/k8sutil" "github.com/replicatedhq/kots/pkg/logger" "github.com/replicatedhq/kots/pkg/operator" @@ -26,30 +25,6 @@ import ( applicationv1beta1 "sigs.k8s.io/application/api/v1beta1" ) -type DownstreamGitOps struct { -} - -func (d *DownstreamGitOps) CreateGitOpsDownstreamCommit(appID string, clusterID string, newSequence int, filesInDir string, downstreamName string) (string, error) { - downstreamGitOps, err := gitops.GetDownstreamGitOps(appID, clusterID) - if err != nil { - return "", errors.Wrap(err, "failed to get downstream gitops") - } - if downstreamGitOps == nil || !downstreamGitOps.IsConnected { - return "", nil - } - - a, err := store.GetStore().GetApp(appID) - if err != nil { - return "", errors.Wrap(err, "failed to get app") - } - createdCommitURL, err := gitops.CreateGitOpsCommit(downstreamGitOps, a.Slug, a.Name, int(newSequence), filesInDir, downstreamName) - if err != nil { - return "", errors.Wrap(err, "failed to create gitops commit") - } - - return createdCommitURL, nil -} - // DeployVersion deploys the version for the given sequence func DeployVersion(appID string, sequence int64) error { blocked, err := isBlockedDueToStrictPreFlights(appID, sequence) diff --git a/web/dist/README.md b/web/dist/README.md index 7f1a1f8cb0..5bef7378e3 100644 --- a/web/dist/README.md +++ b/web/dist/README.md @@ -1,3 +1,3 @@ -The dist directory is used in the go build to embed the compiled web resources into the kotsadm binary. Because -web isn't always built (testing, okteto, etc), this README.md will allow compiling of the go binary without first -building web. \ No newline at end of file +The dist directory is used in the go build to embed the compiled web resources into the kots & kotsadm binaries. Because +web isn't always built (testing, dev, etc), this README.md will allow compiling of the go binary without first +building web. diff --git a/web/src/ConnectionTerminated.jsx b/web/src/ConnectionTerminated.jsx index ecb0929e72..5bd173838d 100644 --- a/web/src/ConnectionTerminated.jsx +++ b/web/src/ConnectionTerminated.jsx @@ -40,9 +40,12 @@ export default class ConnectionTerminated extends Component { 10000 ) .then(async (res) => { - if (res.status === 401) { - Utilities.logoutUser(); - return; + if (!res.ok) { + if (res.status === 401) { + Utilities.logoutUser(); + return; + } + throw new Error(`Unexpected status code: ${res.status}`); } this.props.setTerminatedState(false); }) diff --git a/web/src/Root.tsx b/web/src/Root.tsx index 8412cdab1a..c5cc2d583c 100644 --- a/web/src/Root.tsx +++ b/web/src/Root.tsx @@ -34,11 +34,6 @@ import TroubleshootContainer from "@components/troubleshoot/TroubleshootContaine import Footer from "./components/shared/Footer"; import NavBar from "./components/shared/NavBar"; - -// scss -import "./scss/index.scss"; -// tailwind -import "./index.css"; import connectHistory from "./services/matomo"; // types @@ -59,7 +54,7 @@ import SnapshotRestore from "@components/snapshots/SnapshotRestore"; import AppSnapshots from "@components/snapshots/AppSnapshots"; import AppSnapshotRestore from "@components/snapshots/AppSnapshotRestore"; import EmbeddedClusterViewNode from "@components/apps/EmbeddedClusterViewNode"; -import EmbeddedClusterUpgrading from "@components/clusters/EmbeddedClusterUpgrading"; +import UpgradeStatusModal from "@components/modals/UpgradeStatusModal"; // react-query client const queryClient = new QueryClient(); @@ -94,7 +89,11 @@ type State = { appSlugFromMetadata: string | null; adminConsoleMetadata: Metadata | null; connectionTerminated: boolean; - shouldShowClusterUpgradeModal: boolean; + showUpgradeStatusModal: boolean; + upgradeStatus?: string; + upgradeMessage?: string; + upgradeAppSlug?: string; + clusterState: string; errLoggingOut: string; featureFlags: object; fetchingMetadata: boolean; @@ -121,7 +120,11 @@ const Root = () => { appNameSpace: null, adminConsoleMetadata: null, connectionTerminated: false, - shouldShowClusterUpgradeModal: false, + showUpgradeStatusModal: false, + upgradeStatus: "", + upgradeMessage: "", + upgradeAppSlug: "", + clusterState: "", errLoggingOut: "", featureFlags: {}, fetchingMetadata: false, @@ -247,6 +250,54 @@ const Root = () => { } }; + const fetchUpgradeStatus = async (appSlug) => { + try { + const res = await fetch( + `${process.env.API_ENDPOINT}/app/${appSlug}/task/upgrade-service`, + { + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + method: "GET", + } + ); + if (!res.ok) { + if (res.status === 401) { + Utilities.logoutUser(); + return; + } + console.log( + "failed to get upgrade service status, unexpected status code", + res.status + ); + return; + } + const response = await res.json(); + const status = response.status; + if ( + status === "upgrading-cluster" || + status === "upgrading-app" || + status === "upgrade-failed" + ) { + setState({ + showUpgradeStatusModal: true, + upgradeStatus: status, + upgradeMessage: response.currentMessage, + upgradeAppSlug: appSlug, + }); + return; + } + if (state.showUpgradeStatusModal) { + // upgrade finished, reload the page + window.location.reload(); + return; + } + } catch (err) { + throw err; + } + }; + const fetchKotsAppMetadata = async () => { setState({ fetchingMetadata: true }); @@ -301,12 +352,15 @@ const Root = () => { }, 10000 ) - .then(async (result) => { - if (result.status === 401) { - Utilities.logoutUser(); - return; + .then(async (res) => { + if (!res.ok) { + if (res.status === 401) { + Utilities.logoutUser(); + return; + } + throw new Error(`Unexpected status code: ${res.status}`); } - const body = await result.json(); + const body = await res.json(); setState({ connectionTerminated: false, snapshotInProgressApps: body.snapshotInProgressApps, @@ -326,13 +380,17 @@ const Root = () => { }); }; - const onRootMounted = () => { + const onRootMounted = async () => { fetchKotsAppMetadata(); if (Utilities.isLoggedIn()) { ping(); getAppsList().then((appsList) => { - if (appsList?.length > 0 && window.location.pathname === "/apps") { - const { slug } = appsList[0]; + if (!appsList?.length) { + return; + } + const { slug } = appsList[0]; + fetchUpgradeStatus(slug); + if (window.location.pathname === "/apps") { history.replace(`/app/${slug}`); } }); @@ -493,6 +551,9 @@ const Root = () => { } /> @@ -682,19 +743,13 @@ const Root = () => { appNameSpace={state.appNameSpace} appName={state.selectedAppName} refetchAppsList={getAppsList} + refetchUpgradeStatus={fetchUpgradeStatus} snapshotInProgressApps={state.snapshotInProgressApps} ping={ping} isEmbeddedCluster={Boolean( state.adminConsoleMetadata?.isEmbeddedCluster )} - setShouldShowClusterUpgradeModal={( - shouldShowClusterUpgradeModal: boolean - ) => { - setState({ - shouldShowClusterUpgradeModal: - shouldShowClusterUpgradeModal, - }); - }} + showUpgradeStatusModal={state.showUpgradeStatusModal} /> } /> @@ -707,18 +762,13 @@ const Root = () => { appNameSpace={state.appNameSpace} appName={state.selectedAppName} refetchAppsList={getAppsList} + refetchUpgradeStatus={fetchUpgradeStatus} snapshotInProgressApps={state.snapshotInProgressApps} ping={ping} isEmbeddedCluster={Boolean( state.adminConsoleMetadata?.isEmbeddedCluster )} - setShouldShowClusterUpgradeModal={( - showUpgradeModal: boolean - ) => { - setState({ - shouldShowClusterUpgradeModal: showUpgradeModal, - }); - }} + showUpgradeStatusModal={state.showUpgradeStatusModal} /> } > @@ -765,6 +815,9 @@ const Root = () => { } /> @@ -874,31 +927,51 @@ const Root = () => { - - {!state.shouldShowClusterUpgradeModal && ( - { + // cannot close the modal while upgrading + if (state.upgradeStatus === "upgrade-failed") { + setState({ showUpgradeStatusModal: false }); + } + }} + shouldReturnFocusAfterClose={false} + contentLabel="Upgrade status modal" + ariaHideApp={false} + className="Modal DefaultSize" + > + setState({ showUpgradeStatusModal: false })} connectionTerminated={state.connectionTerminated} - appLogo={state.appLogo} setTerminatedState={(status: boolean) => setState({ connectionTerminated: status }) } /> - )} - {state.shouldShowClusterUpgradeModal && ( - + ) : ( + + setState({ connectionTerminated: status }) } /> - )} - + + )} ); }; diff --git a/web/src/components/AppConfigRenderer.jsx b/web/src/components/AppConfigRenderer.jsx deleted file mode 100644 index 73073e968a..0000000000 --- a/web/src/components/AppConfigRenderer.jsx +++ /dev/null @@ -1,54 +0,0 @@ -import ConfigRender from "./config_render/ConfigRender"; -import PropTypes from "prop-types"; -import map from "lodash/map"; -import sortBy from "lodash/sortBy"; -import keyBy from "lodash/keyBy"; -import { Component } from "react"; - -export class AppConfigRenderer extends Component { - static propTypes = { - groups: PropTypes.array.isRequired, // Config groups items to render - handleChange: PropTypes.func, - getData: PropTypes.func, - }; - - static defaultProps = { - groups: [], - }; - - constructor(props) { - super(props); - } - - render() { - const { groups, readonly } = this.props; - const orderedFields = sortBy(groups, "position"); - const _groups = keyBy(orderedFields, "name"); - const groupsList = map(groups, "name"); - - return ( -
- { - return; - }) - } - getData={ - this.props.getData || - (() => { - return; - }) - } - readonly={readonly} - configSequence={this.props.configSequence} - appSlug={this.props.appSlug} - /> -
- ); - } -} diff --git a/web/src/components/AppConfigRenderer.tsx b/web/src/components/AppConfigRenderer.tsx new file mode 100644 index 0000000000..d893cf76d9 --- /dev/null +++ b/web/src/components/AppConfigRenderer.tsx @@ -0,0 +1,81 @@ +import ConfigRender from "./config_render/ConfigRender"; +import map from "lodash/map"; +import sortBy from "lodash/sortBy"; +import keyBy from "lodash/keyBy"; + +type ConfigGroupItem = { + default: string; + error: string; + hidden: boolean; + name: string; + required: boolean; + title: string; + type: string; + validationError: string; + value: string; + when: "true" | "false"; +}; + +type ConfigGroup = { + hidden: boolean; + hasError: boolean; + items: ConfigGroupItem[]; + name: string; + title: string; + when: "true" | "false"; +}; + +interface AppConfigRendererProps { + appSlug: string; + configSequence: string; + getData: (group: ConfigGroup[]) => void; + groups: ConfigGroup[]; + handleChange?: () => void; + handleDownloadFile: (filename: string) => void; + readonly?: boolean; +} + +export const AppConfigRenderer = ({ + groups, + handleChange, + getData, + handleDownloadFile, + readonly = false, + configSequence, + appSlug, +}: AppConfigRendererProps) => { + const orderedFields = sortBy(groups, "position"); + const _groups = keyBy(orderedFields, "name"); + const groupsList = map(groups, "name"); + + return ( +
+ { + return; + }) + } + getData={ + getData || + (() => { + return; + }) + } + handleDownloadFile={ + handleDownloadFile || + (() => { + return; + }) + } + readonly={readonly} + configSequence={configSequence} + appSlug={appSlug} + /> +
+ ); +}; diff --git a/web/src/components/UploadLicenseFile.tsx b/web/src/components/UploadLicenseFile.tsx index 08113fdfec..1cdd89ca03 100644 --- a/web/src/components/UploadLicenseFile.tsx +++ b/web/src/components/UploadLicenseFile.tsx @@ -508,13 +508,13 @@ const UploadLicenseFile = (props: Props) => {

Select the application that you want to install.

- {/* TODO: there's probably a bug here*/} - {/*@ts-ignore*/} { options={availableDestinations} isSearchable={false} getOptionLabel={(destination) => + // TODO: upgrade react-select and use the current typing + // We want to display element instead of string + // @ts-ignore this.getDestinationLabel( destination, destination.label diff --git a/web/src/components/upgrade_service/AppConfig.tsx b/web/src/components/upgrade_service/AppConfig.tsx new file mode 100644 index 0000000000..91fe37c5e1 --- /dev/null +++ b/web/src/components/upgrade_service/AppConfig.tsx @@ -0,0 +1,638 @@ +import classNames from "classnames"; +import debounce from "lodash/debounce"; +import find from "lodash/find"; +import map from "lodash/map"; +import { useEffect, useReducer } from "react"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; + +import { AppConfigRenderer } from "@src/components/AppConfigRenderer"; +import Icon from "@src/components/Icon"; +import Loader from "@src/components/shared/Loader"; +import { withRouter } from "@src/utilities/react-router-utilities"; +import { useUpgradeServiceContext } from "./UpgradeServiceContext"; + +import "@src/scss/components/watches/WatchConfig.scss"; + +// This was typed from the implementation of the component so it might be wrong +type ConfigGroup = { + hidden: boolean; + hasError: boolean; + items: ConfigGroupItem[]; + name: string; + title: string; + when: "true" | "false"; +}; + +interface ConfigGroupItemValidationErrors { + item_errors: ConfigGroupItemValidationError[]; + name: string; +} + +interface ConfigGroupItemValidationError { + name: string; + validation_errors: { + message: string; + }[]; +} + +type ConfigGroupItem = { + default: string; + error: string; + hidden: boolean; + name: string; + required: boolean; + title: string; + type: string; + validationError: string; + value: string; + when: "true" | "false"; +}; + +type RequiredItems = string[]; + +const validationErrorMessage = + "Error detected. Please use config nav to the left to locate and fix issues."; + +export const AppConfig = ({ + setCurrentStep, +}: { + setCurrentStep: (step: Number) => void; +}) => { + const location = useLocation(); + const navigate = useNavigate(); + const params = useParams(); + + const { config, setConfig } = useUpgradeServiceContext(); + + const [state, setState] = useReducer( + (currentState, newState) => ({ ...currentState, ...newState }), + { + activeGroups: [], + showConfigError: false, + configErrorMessage: "", + configGroups: [], + configLoading: false, + displayErrorModal: false, + errorTitle: "", + gettingConfigErrMsg: "", + showValidationError: false, + initialConfigGroups: [], + lastLocation: "", + } + ); + useEffect(() => { + setState({ lastLocation: location.hash }); + }, [location.hash]); + + useEffect(() => { + // need to dig into this more + if (location.hash !== state.lastLocation && location.hash) { + // navigate to error if there is one + if (state.showConfigError) { + const hash = location.hash.slice(1); + const element = document.getElementById(hash); + if (element) { + element.scrollIntoView(); + } + } + } + }, [state.configGroups]); + + const navigateToCurrentHash = () => { + const hash = location.hash.slice(1); + // slice `-group` off the end of the hash + const slicedHash = hash.slice(0, -6); + let activeGroupName = null; + state.configGroups.map((group: ConfigGroup) => { + const activeItem = find(group.items, ["name", slicedHash]); + if (activeItem) { + activeGroupName = group.name; + } + }); + + if (activeGroupName) { + setState({ activeGroups: [activeGroupName], configLoading: false }); + // TODO: add error handling for when the element with this hash id is not found + document.getElementById(hash)?.scrollIntoView(); + } + }; + + const getConfig = async () => { + const { slug } = params; + + if (config) { + setState({ + configGroups: config, + + configLoading: false, + }); + } else { + setState({ + configLoading: true, + gettingConfigErrMsg: "", + showConfigError: false, + configErrorMessage: "", + }); + fetch( + `${process.env.API_ENDPOINT}/upgrade-service/app/${slug}/config${window.location.search}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + } + ) + .then(async (response) => { + if (!response.ok) { + const res = await response.json(); + throw new Error(res.error); + } + const data = await response.json(); + if (!data.configGroups?.length) { + throw new Error("No config data found"); + } + setState({ + configGroups: data.configGroups, + configLoading: false, + }); + + if (location.hash.length > 0) { + navigateToCurrentHash(); + } else { + setState({ + activeGroups: [data.configGroups[0].name], + configLoading: false, + // i removed the jsx that renders err modal + gettingConfigErrMsg: "", + }); + } + }) + .catch((err) => { + setState({ + configLoading: false, + errorTitle: `Failed to get config data`, + displayErrorModal: true, + gettingConfigErrMsg: err + ? err.message + : "Something went wrong, please try again.", + }); + }); + } + }; + useEffect(() => { + getConfig(); + setCurrentStep(0); + }, []); + + const markRequiredItems = (requiredItems: RequiredItems) => { + const configGroups = state.configGroups; + requiredItems.forEach((requiredItem) => { + configGroups.forEach((configGroup: ConfigGroup) => { + const item = configGroup.items.find((i) => i.name === requiredItem); + if (item) { + item.error = "This item is required"; + } + }); + }); + setState({ configGroups, showConfigError: true }); + }; + // this runs on config update and when save is clicked but before the request is submitted + // on update it uses the errors from the liveconfig endpoint + // on save it's mostly used to find required field errors + const mergeConfigGroupsAndValidationErrors = ( + groups: ConfigGroup[], + validationErrors: ConfigGroupItemValidationErrors[] + ): [ConfigGroup[], boolean] => { + let hasValidationError = false; + + const newGroups = groups?.map((group: ConfigGroup) => { + const newGroup = { ...group }; + const configGroupValidationErrors = validationErrors?.find( + (validationError) => validationError.name === group.name + ); + + // required errors are handled separately + if (group?.items?.find((item) => item.error)) { + newGroup.hasError = true; + } + + if (configGroupValidationErrors) { + newGroup.items = newGroup?.items?.map((item: ConfigGroupItem) => { + const itemValidationError = + configGroupValidationErrors?.item_errors?.find( + (validationError) => validationError.name === item.name + ); + + if (itemValidationError) { + item.validationError = + itemValidationError?.validation_errors?.[0]?.message; + newGroup.hasError = true; + // if there is an error, then block form submission with state.hasValidationError + if (!hasValidationError) { + hasValidationError = true; + } + } + return item; + }); + } + return newGroup; + }); + return [newGroups, hasValidationError]; + }; + + const handleNext = async () => { + const { slug } = params; + + setState({ + savingConfig: true, + }); + + const url = `${process.env.API_ENDPOINT}/upgrade-service/app/${slug}/config${window.location.search}`; + fetch(url, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify({ + configGroups: state.configGroups, + }), + }) + .then((res) => res.json()) + .then(async (result) => { + if (!result.success) { + if (result.requiredItems?.length) { + markRequiredItems(result.requiredItems); + } + if (result.error) { + setState({ + showConfigError: Boolean(result.error), + configErrorMessage: result.error, + }); + } + + const validationErrors: ConfigGroupItemValidationErrors[] = + result.validationErrors; + const [newGroups, hasValidationError] = + mergeConfigGroupsAndValidationErrors( + state.configGroups, + validationErrors + ); + setState({ + configGroups: newGroups, + showValidationError: hasValidationError, + }); + if (result.error) { + setState({ + showConfigError: Boolean(result.error), + configErrorMessage: result.error, + showValidationError: true, + savingConfig: false, + }); + } + } else { + // @ts-ignore + setConfig(state.configGroups); + navigate(`/upgrade-service/app/${slug}/preflight`, { + replace: true, + }); + setState({ + savingConfig: false, + }); + } + }) + .catch((err) => { + setState({ + savingConfig: false, + showConfigError: Boolean(err), + configErrorMessage: err + ? err.message + : "Something went wrong, please try again.", + }); + }); + }; + + const getItemInConfigGroups = ( + configGroups: ConfigGroup[], + itemName: string + ): ConfigGroupItem | undefined => { + let foundItem; + map(configGroups, (group) => { + map(group.items, (item) => { + if (item.name === itemName) { + foundItem = item; + } + }); + }); + return foundItem; + }; + + let fetchController: AbortController | null = null; + + const handleConfigChange = debounce((groups: ConfigGroup[]) => { + const { slug } = params; + + // cancel current request (if any) + if (fetchController) { + fetchController.abort(); + } + + setState({ + showConfigError: false, + configErrorMessage: "", + }); + + fetchController = new AbortController(); + const signal = fetchController.signal; + + fetch( + `${process.env.API_ENDPOINT}/upgrade-service/app/${slug}/liveconfig${window.location.search}`, + { + signal, + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + credentials: "include", + method: "POST", + body: JSON.stringify({ configGroups: groups }), + } + ) + .then(async (response) => { + if (!response.ok) { + const res = await response.json(); + setState({ + showConfigError: Boolean(res?.error), + configErrorMessage: res?.error, + }); + return; + } + + const data = await response.json(); + const oldGroups = state.configGroups; + const validationErrors: ConfigGroupItemValidationErrors[] = + data.validationErrors; + + // track errors at the form level + setState({ showValidationError: false }); + + // merge validation errors and config group + const [newGroups, hasValidationError] = + mergeConfigGroupsAndValidationErrors( + data.configGroups, + validationErrors + ); + + setState({ + showValidationError: hasValidationError, + }); + + map(newGroups, (group) => { + if (!group.items) { + return; + } + group.items.forEach((newItem: ConfigGroupItem) => { + if (newItem.type === "password") { + const oldItem = getItemInConfigGroups(oldGroups, newItem.name); + if (oldItem) { + newItem.value = oldItem.value; + } + } + }); + }); + + setState({ configGroups: newGroups }); + }) + .catch((error) => { + if (error?.name !== "AbortError") { + console.log(error); + setState({ + showConfigError: Boolean(error?.message), + configErrorMessage: error?.message, + }); + } + }); + }, 250); + + const handleDownloadFile = async (fileName: string) => { + const { slug } = params; + const url = `${process.env.API_ENDPOINT}/upgrade-service/app/${slug}/config/${fileName}/download${window.location.search}`; + fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/octet-stream", + }, + credentials: "include", + }) + .then((response) => { + if (!response.ok) { + throw Error(response.statusText); // TODO: handle error + } + return response.blob(); + }) + .then((blob) => { + const downloadURL = window.URL.createObjectURL(new Blob([blob])); + const link = document.createElement("a"); + link.href = downloadURL; + link.setAttribute("download", fileName); + document.body.appendChild(link); + link.click(); + link.parentNode?.removeChild(link); + }) + .catch(function (error) { + console.log(error); // TODO handle error + }); + }; + + const toggleActiveGroups = (name: string) => { + let groupsArr = state.activeGroups; + if (groupsArr.includes(name)) { + let updatedGroupsArr = groupsArr.filter((n: string) => n !== name); + setState({ activeGroups: updatedGroupsArr }); + } else { + groupsArr.push(name); + setState({ activeGroups: groupsArr }); + } + }; + + const { + showConfigError, + configErrorMessage, + configGroups, + configLoading, + showValidationError, + } = state; + + if (configLoading) { + return ( +
+ +
+ ); + } + + const sections = document.querySelectorAll(".observe-elements"); + + const callback = (entries: IntersectionObserverEntry[]) => { + entries.forEach(({ isIntersecting, target }) => { + // find the group nav link that matches the current section in view + const groupNav = document.querySelector(`#config-group-nav-${target.id}`); + // find the active link in the group nav + const activeLink = document.querySelector(".active-item"); + const hash = location.hash.slice(1); + const activeLinkByHash = document.querySelector(`a[href='#${hash}']`); + if (isIntersecting) { + groupNav?.classList.add("is-active"); + // if your group is active, item will be active + if (activeLinkByHash && groupNav?.contains(activeLinkByHash)) { + activeLinkByHash.classList.add("active-item"); + } + } else { + // if the section is not in view, remove the highlight from the active link + if (groupNav?.contains(activeLink) && activeLink) { + activeLink.classList.remove("active-item"); + } + // remove the highlight from the group nav link + groupNav?.classList.remove("is-active"); + } + }); + }; + + const options = { + root: document, + // rootMargin is the amount of space around the root element that the intersection observer will look for intersections + rootMargin: "20% 0% -75% 0%", + // threshold: the proportion of the element that must be within the root bounds for it to be considered intersecting + threshold: 0.15, + }; + + const observer = new IntersectionObserver(callback, options); + + sections.forEach((section) => { + observer.observe(section); + }); + + return ( +
+
+
+
+ {configGroups?.map((group: ConfigGroup, i: string) => { + if ( + group.title === "" || + group.title.length === 0 || + group.hidden || + group.when === "false" + ) { + return; + } + return ( +
+
toggleActiveGroups(group.name)} + > +
+ {group.title} +
+ {/* adding the arrow-down classes, will rotate the icon when clicked */} + +
+ {group.items ? ( +
+ {group.items + ?.filter((item) => item.type !== "label") + ?.map((item, j) => { + const hash = location.hash.slice(1); + if (item.hidden || item.when === "false") { + return; + } + return ( + + {item.title} + + ); + })} +
+ ) : null} +
+ ); + })} +
+
+
+
+ +
+
+
+ {(showConfigError || state.showValidationError) && ( + + {configErrorMessage || validationErrorMessage} + + )} + +
+
+
+
{" "} +
+
+
+ ); +}; + +/* eslint-disable */ +// @ts-ignore +const AppConfigWithRouter: any = withRouter(AppConfig); + +export default AppConfigWithRouter; +/* eslint-enable */ diff --git a/web/src/components/upgrade_service/ConfirmAndDeploy.tsx b/web/src/components/upgrade_service/ConfirmAndDeploy.tsx new file mode 100644 index 0000000000..db30542504 --- /dev/null +++ b/web/src/components/upgrade_service/ConfirmAndDeploy.tsx @@ -0,0 +1,350 @@ +import { useEffect, useState } from "react"; +import Modal from "react-modal"; +// TODO: add type checking support for react-remarkable or add a global ignore +// @ts-ignore +import Markdown from "react-remarkable"; +import { useLocation, useNavigate, useParams } from "react-router-dom"; + +import { KotsPageTitle } from "@components/Head"; +import Icon from "@components/Icon"; +import SkipPreflightsModal from "@components/shared/modals/SkipPreflightsModal"; +import PreflightsProgress from "@components/troubleshoot/PreflightsProgress"; +import { useApps } from "@features/App"; +import { useDeployAppVersion, useGetPrelightResults } from "./hooks/index"; +import { KotsParams } from "@types"; + +import "../../scss/components/PreflightCheckPage.scss"; +import { useUpgradeServiceContext } from "./UpgradeServiceContext"; + +interface PreflightResultResponse { + learnMoreUri: string; + message: string; + title: string; + showCannotFail: boolean; + showFail: boolean; + showPass: boolean; + showWarn: boolean; +} + +const ConfirmAndDeploy = ({ + setCurrentStep, +}: { + setCurrentStep: (step: number) => void; +}) => { + useEffect(() => { + setCurrentStep(2); + }, []); + const navigate = useNavigate(); + const [ + showContinueWithFailedPreflightsModal, + setShowContinueWithFailedPreflightsModal, + ] = useState(false); + const [ + showConfirmIgnorePreflightsModal, + setShowConfirmIgnorePreflightsModal, + ] = useState(false); + + const { isSkipPreflights, continueWithFailedPreflights } = + useUpgradeServiceContext(); + + const closeModal = async () => { + window.parent.postMessage({ message: "closeUpgradeServiceModal" }); + }; + + const { sequence = "0", slug } = useParams() as KotsParams; + const { mutate: deployKotsDownstream, isLoading } = useDeployAppVersion({ + slug, + sequence, + closeModal, + }); + + const { data: preflightCheck, error: getPreflightResultsError } = + useGetPrelightResults({ sequence, slug }); + + // probably isn't necessary to have this here + if (!preflightCheck?.showPreflightCheckPending) { + if (showConfirmIgnorePreflightsModal) { + setShowConfirmIgnorePreflightsModal(false); + } + } + + const PreflightResult = ({ + results, + }: { + results: PreflightResultResponse[]; + }) => { + function hasAllPassed(data: PreflightResultResponse[]) { + return data.every((item) => item.showPass); + } + + function hasWarning(data: PreflightResultResponse[]) { + return data.some((item) => item.showWarn); + } + function hasFailed(data: PreflightResultResponse[]) { + return data.some((item) => item.showFail); + } + + const warnings = results.filter( + (result: PreflightResultResponse) => result.showWarn + ); + const errors = results.filter( + (result: PreflightResultResponse) => result.showFail + ); + + // go through and find out if there are warnings + if (hasAllPassed(results)) { + return ( +
+ +
+ All preflight checks passed +
+
+ ); + } else if (hasFailed(results)) { + return ( +
+
+ +
+ Preflight checks failed +
+
+ {errors.map((error, i) => { + return ( +
+
+

+ {error.title} +

+
+ +
+ {error.showCannotFail && ( +

+ To deploy the application, this check cannot fail. +

+ )} +
+
+ ); + })} +
+ ); + } else if (hasWarning(results)) { + return ( +
+
+ +
+ Preflight checks passed with warnings +
+
+ {warnings.map((warning, i) => { + return ( +
+
+

+ {warning.title} +

+
+ +
+
+
+ ); + })} +
+ ); + } + + return
; + }; + const location = useLocation(); + + const { refetch: refetchApps } = useApps(); + + return ( +
+ +
+ {location.pathname.includes("version-history") && ( +
navigate(-1)}> + + Back +
+ )} +
+ {getPreflightResultsError?.message && ( +
+
+
+

Encountered an error

+

{getPreflightResultsError.message}

+
+
+ )} + +

+ Confirm and Deploy +

+ + {preflightCheck?.showPreflightCheckPending && ( +
+ +
+ )} +
+
+

+ Config +

+
+ +
+ +
+ No errors detected. +
+
+
+ + {preflightCheck?.showPreflightResults && ( +
+
+

+ Preflight checks +

+
+
+ +
+
+ )} + + {preflightCheck?.showIgnorePreflight && ( +
+ setShowConfirmIgnorePreflightsModal(true)} + > + Ignore Preflights{" "} + +
+ )} +
+
+ + +
+
+ + {showConfirmIgnorePreflightsModal && ( + setShowConfirmIgnorePreflightsModal(false)} + onIgnorePreflightsAndDeployClick={() => { + deployKotsDownstream({ + continueWithFailedPreflights: false, + isSkipPreflights: true, + }); + }} + showSkipModal={showConfirmIgnorePreflightsModal} + /> + )} + + setShowContinueWithFailedPreflightsModal(false)} + shouldReturnFocusAfterClose={false} + contentLabel="Preflight shows some issues" + ariaHideApp={false} + className="Modal" + > +
+

+ Some preflight checks did not pass.
Are you sure you want to + deploy? +

+
+ + +
+
+
+
+ ); +}; + +export default ConfirmAndDeploy; diff --git a/web/src/components/upgrade_service/PreflightChecks.tsx b/web/src/components/upgrade_service/PreflightChecks.tsx new file mode 100644 index 0000000000..89a7a511f5 --- /dev/null +++ b/web/src/components/upgrade_service/PreflightChecks.tsx @@ -0,0 +1,174 @@ +import { KotsPageTitle } from "@components/Head"; +import { useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; + +import PreflightRenderer from "@components/PreflightRenderer"; +import SkipPreflightsModal from "@components/shared/modals/SkipPreflightsModal"; + +import PreflightsProgress from "@components/troubleshoot/PreflightsProgress"; +import "../../scss/components/PreflightCheckPage.scss"; + +import { useGetPrelightResults, useRerunPreflights } from "./hooks/index"; + +import { KotsParams } from "@types"; +import { useUpgradeServiceContext } from "./UpgradeServiceContext"; + +const PreflightCheck = ({ + setCurrentStep, +}: { + setCurrentStep: (step: number) => void; +}) => { + const navigate = useNavigate(); + + const [ + showConfirmIgnorePreflightsModal, + setShowConfirmIgnorePreflightsModal, + ] = useState(false); + + const { setIsSkipPreflights, setContinueWithFailedPreflights } = + useUpgradeServiceContext(); + + const { sequence = "0", slug } = useParams() as KotsParams; + + const { data: preflightCheck, error: getPreflightResultsError } = + useGetPrelightResults({ slug, sequence }); + const { mutate: rerunPreflights, error: rerunPreflightsError } = + useRerunPreflights({ slug, sequence }); + + if (!preflightCheck?.showPreflightCheckPending) { + if (showConfirmIgnorePreflightsModal) { + setShowConfirmIgnorePreflightsModal(false); + } + } + + useEffect(() => { + setCurrentStep(1); + }, []); + + const handleIgnorePreflights = () => { + setContinueWithFailedPreflights(false); + setIsSkipPreflights(true); + navigate(`/upgrade-service/app/${slug}/deploy`); + }; + + return ( +
+ +
+
+ {getPreflightResultsError?.message && ( +
+
+
+

Encountered an error

+

{getPreflightResultsError.message}

+
+
+ )} + + {rerunPreflightsError?.message && ( +
+
+
+

Encountered an error

+

{rerunPreflightsError.message}

+
+
+ )} +

+ Preflight checks +

+

+ Preflight checks validate that your cluster meets the minimum + requirements. Required checks must pass in order to deploy the + application. Optional checks are recommended to ensure that the + application will work as intended. +

+ + {preflightCheck?.showPreflightCheckPending && ( +
+ +
+ )} + + {preflightCheck?.showPreflightResults && ( +
+
+

+ Results +

+ {preflightCheck?.shouldShowRerunPreflight && ( + + )} +
+
+ +
+
+ )} + + {preflightCheck?.showIgnorePreflight && ( +
+ setShowConfirmIgnorePreflightsModal(true)} + > + Ignore Preflights{" "} + +
+ )} +
+
+ + {!preflightCheck?.showPreflightCheckPending && ( + + )} +
+
+ + {showConfirmIgnorePreflightsModal && ( + setShowConfirmIgnorePreflightsModal(false)} + onIgnorePreflightsAndDeployClick={() => { + handleIgnorePreflights(); + }} + showSkipModal={showConfirmIgnorePreflightsModal} + isEmbeddedCluster={true} + /> + )} +
+ ); +}; + +export default PreflightCheck; diff --git a/web/src/components/upgrade_service/StepIndicator.tsx b/web/src/components/upgrade_service/StepIndicator.tsx new file mode 100644 index 0000000000..38105dfbc4 --- /dev/null +++ b/web/src/components/upgrade_service/StepIndicator.tsx @@ -0,0 +1,52 @@ +interface StepIndicatorProps { + items: string[]; + value: number; + className?: string; +} + +const StepIndicator = ({ items, value, className }: StepIndicatorProps) => { + return ( +
+ {items.map((item, index) => { + const isActive = value === index; + const isLast = index === items.length - 1; + return ( +
+
+
+ {item} +
+
+
+
{index + 1}
+
+
+ {!isLast && ( +
+ )} +
+
+ ); + })} +
+ ); +}; + +export default StepIndicator; diff --git a/web/src/components/upgrade_service/UpgradeService.tsx b/web/src/components/upgrade_service/UpgradeService.tsx new file mode 100644 index 0000000000..fcaf705729 --- /dev/null +++ b/web/src/components/upgrade_service/UpgradeService.tsx @@ -0,0 +1,71 @@ +import { Route, Routes, Navigate } from "react-router-dom"; +import { Helmet } from "react-helmet"; +import NotFound from "@components/static/NotFound"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import AppConfig from "@components/upgrade_service/AppConfig"; + +// types +import { ToastProvider } from "@src/context/ToastContext"; +import StepIndicator from "./StepIndicator"; +import { useState } from "react"; + +import PreflightChecks from "./PreflightChecks"; +import ConfirmAndDeploy from "./ConfirmAndDeploy"; +import { KotsPageTitle } from "@components/Head"; +import { UpgradeServiceProvider } from "./UpgradeServiceContext"; +// react-query client +const queryClient = new QueryClient(); + +const UpgradeService = () => { + const Crashz = () => { + throw new Error("Crashz!"); + }; + const [currentStep, setCurrentStep] = useState(0); + + return ( + + + + + + + + +
+ {" "} + + + } />{" "} + + } /> + } + /> + } + /> + } + /> + + } /> + +
+
+
+
+ ); +}; + +export { UpgradeService }; diff --git a/web/src/components/upgrade_service/UpgradeServiceContext.tsx b/web/src/components/upgrade_service/UpgradeServiceContext.tsx new file mode 100644 index 0000000000..e61c95b9ac --- /dev/null +++ b/web/src/components/upgrade_service/UpgradeServiceContext.tsx @@ -0,0 +1,36 @@ +import { createContext, useContext, useState } from "react"; + +export const UpgradeServiceContext = createContext(null); + +export const UpgradeServiceProvider = ({ children }) => { + const [config, setConfig] = useState(null); + + const [isSkipPreflights, setIsSkipPreflights] = useState(false); + const [continueWithFailedPreflights, setContinueWithFailedPreflights] = + useState(true); + return ( + + {children} + + ); +}; + +export const useUpgradeServiceContext = () => { + const context = useContext(UpgradeServiceContext); + if (!context) { + throw new Error( + "useUpgradeServiceContext must be used within a UpgradeServiceProvider" + ); + } + return context; +}; diff --git a/web/src/components/upgrade_service/hooks/getPreflightResult.tsx b/web/src/components/upgrade_service/hooks/getPreflightResult.tsx new file mode 100644 index 0000000000..4bc412f55f --- /dev/null +++ b/web/src/components/upgrade_service/hooks/getPreflightResult.tsx @@ -0,0 +1,207 @@ +import { useQuery } from "@tanstack/react-query"; +import { + PreflightCheck, + PreflightResponse, +} from "@features/PreflightChecks/types"; +import { useState } from "react"; + +async function getPreflightResult({ + slug, +}: { + apiEndpoint?: string; + slug: string; + sequence?: string; +}): Promise { + const jsonResponse = await fetch( + `${process.env.API_ENDPOINT}/upgrade-service/app/${slug}/preflight/result`, + { + method: "GET", + credentials: "include", + } + ); + + if (!jsonResponse.ok) { + throw new Error( + `Encountered an error while fetching preflight results: Unexpected status code: ${jsonResponse.status}` + ); + } + + try { + const response: PreflightResponse = await jsonResponse.json(); + + // unmarshall these nested JSON strings + if (typeof response?.preflightResult?.result === "string") { + if (response?.preflightResult?.result.length > 0) { + response.preflightResult.result = JSON.parse( + response.preflightResult.result + ); + } else { + response.preflightResult.result = {}; + } + } + + if (typeof response?.preflightProgress === "string") { + if (response?.preflightProgress.length > 0) { + response.preflightProgress = JSON.parse(response.preflightProgress); + } else { + response.preflightProgress = {}; + } + } + + return response; + } catch (err) { + console.error(err); + throw new Error( + "Encountered an error while unmarshalling preflight results" + ); + } +} + +function hasPreflightErrors(response: PreflightResponse): boolean { + if (typeof response?.preflightResult?.result === "string") + throw new Error("Preflight response is not properly unmarshalled"); + + return Boolean(response?.preflightResult?.result?.errors?.length); +} + +// result.results which is an array +function hasPreflightResults(response: PreflightResponse): boolean { + if (typeof response?.preflightResult?.result === "string") + throw new Error("Preflight response is not properly unmarshalled"); + + return Boolean(response?.preflightResult?.result?.results?.length); +} + +// just results which is an object +function hasRunningPreflightChecks(response: PreflightResponse): boolean { + if (typeof response?.preflightResult?.result === "string") + throw new Error("Preflight response is not properly unmarshalled"); + + return Object.keys(response?.preflightResult?.result || {}).length === 0; +} + +function hasFailureOrWarning(response: PreflightResponse): boolean { + if (typeof response?.preflightResult?.result === "string") + throw new Error("Preflight response is not properly unmarshalled"); + + return Boolean( + response?.preflightResult?.result?.results?.find( + (result) => result?.isFail || result?.isWarn + ) + ); +} + +function flattenPreflightResponse({ + refetchCount, + response, +}: { + refetchCount: number; + response: PreflightResponse; +}): PreflightCheck { + if ( + typeof response?.preflightProgress === "string" || + typeof response?.preflightResult?.result === "string" + ) + throw new Error("Preflight response is not properly unmarshalled"); + + return { + // flatten the error strings out into an array + errors: + response?.preflightResult?.result?.errors?.map((error) => error.error) || + [], + pendingPreflightCheckName: response?.preflightProgress?.currentName || "", + // TODO: see if we can calculate a real % + pendingPreflightChecksPercentage: + refetchCount === 0 ? 0 : refetchCount > 21 ? 96 : refetchCount * 4.5, + pollForUpdates: + !response?.preflightResult?.skipped || + hasRunningPreflightChecks(response), + preflightResults: + response?.preflightResult?.result?.results?.map((responseResult) => ({ + learnMoreUri: responseResult.uri || "", + message: responseResult.message || "", + title: responseResult.title || "", + showCannotFail: + (responseResult.isFail && responseResult?.strict) || false, + showFail: responseResult?.isFail || false, + showPass: responseResult?.isPass || false, + showWarn: responseResult?.isWarn || false, + })) || [], + showCancelPreflight: + !response?.preflightResult?.skipped && + (hasPreflightErrors(response) || hasFailureOrWarning(response)), + shouldShowConfirmContinueWithFailedPreflights: + !response?.preflightResult?.skipped && // not skipped + (hasFailureOrWarning(response) || hasPreflightErrors(response)), // or it has errors + shouldShowRerunPreflight: + Boolean(response?.preflightResult?.result) || // not running + response?.preflightResult?.skipped, // not skipped + showDeploymentBlocked: + response?.preflightResult?.hasFailingStrictPreflights, + showIgnorePreflight: + (!response?.preflightResult?.hasFailingStrictPreflights && + response?.preflightResult?.skipped) || + hasRunningPreflightChecks(response), + showPreflightCheckPending: + response?.preflightResult?.skipped || hasRunningPreflightChecks(response), + showPreflightResultErrors: + hasPreflightErrors(response) && // has errors + !response?.preflightResult?.skipped && // not skipped + !hasPreflightResults(response), + showPreflightResults: + !response?.preflightResult?.skipped && + hasPreflightResults(response) && + !hasPreflightErrors(response), + showPreflightSkipped: response?.preflightResult?.skipped, + showRbacError: response?.preflightResult?.result?.errors?.find( + (error) => error?.isRbac + ) + ? true + : false, + }; +} + +function makeRefetchInterval(preflightCheck: PreflightCheck): number | false { + if (preflightCheck.pollForUpdates) return 1000; + + return false; +} + +function useGetPrelightResults({ + slug, + sequence, +}: { + slug: string; + sequence?: string; +}) { + // this is for the progress bar + const [refetchCount, setRefetchCount] = useState(0); + + return useQuery({ + queryFn: () => { + setRefetchCount(refetchCount + 1); + + return getPreflightResult({ slug, sequence }); + }, + queryKey: ["preflight-results", sequence, slug], + onError: (err: Error) => { + console.log(err); + + setRefetchCount(0); + }, + refetchInterval: (preflightCheck: PreflightCheck | undefined) => { + if (!preflightCheck) return null; + if (preflightCheck?.preflightResults.length > 0) return null; + + const refetchInterval = makeRefetchInterval(preflightCheck); + + if (!refetchInterval) setRefetchCount(0); + + return refetchInterval; + }, + select: (response: PreflightResponse) => + flattenPreflightResponse({ response, refetchCount }), + }); +} + +export { useGetPrelightResults }; diff --git a/web/src/components/upgrade_service/hooks/index.ts b/web/src/components/upgrade_service/hooks/index.ts new file mode 100644 index 0000000000..0380064401 --- /dev/null +++ b/web/src/components/upgrade_service/hooks/index.ts @@ -0,0 +1,5 @@ +import { useGetPrelightResults } from "./getPreflightResult"; +import { useRerunPreflights } from "./postPreflightRun"; +import { useDeployAppVersion } from "./postDeployAppVersion"; + +export { useGetPrelightResults, useRerunPreflights, useDeployAppVersion }; diff --git a/web/src/components/upgrade_service/hooks/postDeployAppVersion.tsx b/web/src/components/upgrade_service/hooks/postDeployAppVersion.tsx new file mode 100644 index 0000000000..231d5d1f0b --- /dev/null +++ b/web/src/components/upgrade_service/hooks/postDeployAppVersion.tsx @@ -0,0 +1,81 @@ +import { useMutation } from "@tanstack/react-query"; + +async function postDeployAppVersion({ + slug, + body, +}: { + apiEndpoint?: string; + body: string; + slug: string; + sequence: string; +}) { + const response = await fetch( + `${process.env.API_ENDPOINT}/upgrade-service/app/${slug}/deploy`, + { + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + credentials: "include", + method: "POST", + body, + } + ); + + if (!response.ok) { + throw new Error( + `Encountered an error while trying to deploy app version: ${response.status}` + ); + } +} + +function makeBody({ + continueWithFailedPreflights, + isSkipPreflights, +}: { + continueWithFailedPreflights: boolean; + isSkipPreflights: boolean; +}) { + return JSON.stringify({ + continueWithFailedPreflights, + isSkipPreflights, + }); +} + +function useDeployAppVersion({ + slug, + sequence, + closeModal, +}: { + slug: string; + sequence: string; + closeModal: () => void; +}) { + return useMutation({ + mutationFn: ({ + continueWithFailedPreflights = false, + isSkipPreflights = false, + }: { + continueWithFailedPreflights?: boolean; + isSkipPreflights?: boolean; + }) => + postDeployAppVersion({ + slug, + sequence, + + body: makeBody({ continueWithFailedPreflights, isSkipPreflights }), + }), + onError: (err: Error) => { + console.log(err); + throw new Error( + err.message || + "Encountered an error while trying to deploy downstream version" + ); + }, + onSuccess: () => { + closeModal(); + }, + }); +} + +export { useDeployAppVersion }; diff --git a/web/src/components/upgrade_service/hooks/postPreflightRun.tsx b/web/src/components/upgrade_service/hooks/postPreflightRun.tsx new file mode 100644 index 0000000000..6905c607ee --- /dev/null +++ b/web/src/components/upgrade_service/hooks/postPreflightRun.tsx @@ -0,0 +1,52 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +async function postPreflightRun({ + slug, +}: { + apiEndpoint?: string; + slug: string; + sequence: string; +}) { + const response = await fetch( + `${process.env.API_ENDPOINT}/upgrade-service/app/${slug}/preflight/run`, + { + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + credentials: "include", + method: "POST", + } + ); + + if (!response.ok) { + throw new Error( + `Encountered an error while fetching preflight results: Unexpected status code: ${response.status}` + ); + } +} + +function useRerunPreflights({ + slug, + sequence, +}: { + slug: string; + sequence: string; +}) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => postPreflightRun({ slug, sequence }), + onError: (err: Error) => { + console.log(err); + throw new Error(err.message || "Error running preflight checks"); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["preflight-results", sequence, slug], + }); + }, + }); +} + +export { useRerunPreflights }; diff --git a/web/src/features/AppConfig/components/AppConfig.tsx b/web/src/features/AppConfig/components/AppConfig.tsx index 3e21b0ef67..c013917089 100644 --- a/web/src/features/AppConfig/components/AppConfig.tsx +++ b/web/src/features/AppConfig/components/AppConfig.tsx @@ -25,6 +25,7 @@ type Props = { params: KotsParams; app: App; fromLicenseFlow: boolean; + isEmbeddedCluster: boolean; refreshAppData: () => void; refetchApps: () => void; navigate: ReturnType; @@ -600,10 +601,56 @@ class AppConfig extends Component { }); }; + handleDownloadFile = async (fileName: string) => { + const sequence = this.getSequence(); + const { slug } = this.props.params; + const url = `${process.env.API_ENDPOINT}/app/${slug}/config/${sequence}/${fileName}/download`; + fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/octet-stream", + }, + credentials: "include", + }) + .then((response) => { + if (!response.ok) { + throw Error(response.statusText); // TODO: handle error + } + return response.blob(); + }) + .then((blob) => { + const downloadURL = window.URL.createObjectURL(new Blob([blob])); + const link = document.createElement("a"); + link.href = downloadURL; + link.setAttribute("download", fileName); + document.body.appendChild(link); + link.click(); + link.parentNode?.removeChild(link); + }) + .catch(function (error) { + console.log(error); // TODO handle error + }); + }; + hideNextStepModal = () => { this.setState({ showNextStepModal: false }); }; + isConfigReadOnly = (app: App) => { + const { params, isEmbeddedCluster } = this.props; + if (!params.sequence) { + return false; + } + if (!isEmbeddedCluster) { + return false; + } + // in embedded cluster, past versions cannot be edited + const isPastVersion = find(app.downstream?.pastVersions, { + sequence: parseInt(params.sequence), + }); + return !!isPastVersion; + }; + toggleActiveGroups = (name: string) => { let groupsArr = this.state.activeGroups; if (groupsArr.includes(name)) { @@ -810,10 +857,12 @@ class AppConfig extends Component { >
@@ -832,7 +881,9 @@ class AppConfig extends Component {
{version.hasConfig && (
- - + +
@@ -424,23 +431,6 @@ function AppVersionHistoryRow(props: Props) { }); if (!isPastVersion && !isPendingDeployedVersion) { - if ( - Utilities.isPendingClusterUpgrade(selectedApp) && - version.status === "deployed" - ) { - return ( - - - {selectedApp?.appState !== "ready" - ? "Waiting for app to be ready" - : "Updating cluster"} - - ); - } - if (version.status === "deployed") { return (
diff --git a/web/src/features/Dashboard/components/AppStatus.tsx b/web/src/features/Dashboard/components/AppStatus.tsx index 71b23e893f..5778d3b80f 100644 --- a/web/src/features/Dashboard/components/AppStatus.tsx +++ b/web/src/features/Dashboard/components/AppStatus.tsx @@ -142,7 +142,7 @@ export default class AppStatus extends Component { : "u-textColor--error" }`} > - {Utilities.clusterState(embeddedClusterState)} + {Utilities.humanReadableClusterState(embeddedClusterState)} )} diff --git a/web/src/features/Dashboard/components/Dashboard.tsx b/web/src/features/Dashboard/components/Dashboard.tsx index ce6c29eded..ced085f61c 100644 --- a/web/src/features/Dashboard/components/Dashboard.tsx +++ b/web/src/features/Dashboard/components/Dashboard.tsx @@ -73,6 +73,7 @@ type OutletContext = { refreshAppData: () => void; toggleIsBundleUploading: (isUploading: boolean) => void; updateCallback: () => void | null; + showUpgradeStatusModal: boolean; }; // TODO: update these strings so that they are not nullable (maybe just set default to "") @@ -168,6 +169,7 @@ const Dashboard = (props: Props) => { refreshAppData, toggleIsBundleUploading, updateCallback, + showUpgradeStatusModal, }: OutletContext = useOutletContext(); const params = useParams(); const airgapUploader = useRef(null); @@ -230,8 +232,8 @@ const Dashboard = (props: Props) => { }; const onUpdateDownloadStatusError = (data: Error) => { - if (Utilities.isPendingClusterUpgrade(app)) { - // if the cluster is upgrading, we don't want to show an error + if (showUpgradeStatusModal) { + // if an upgrade is in progress, we don't want to show an error return; } @@ -593,8 +595,8 @@ const Dashboard = (props: Props) => { }; const onError = (err: Error) => { - if (Utilities.isPendingClusterUpgrade(app)) { - // if the cluster is upgrading, we don't want to show an error + if (showUpgradeStatusModal) { + // if an upgrade is in progress, we don't want to show an error return; } @@ -700,6 +702,7 @@ const Dashboard = (props: Props) => { viewAirgapUploadError={() => toggleViewAirgapUploadError()} showAutomaticUpdatesModal={showAutomaticUpdatesModal} noUpdatesAvalable={state.noUpdatesAvalable} + showUpgradeStatusModal={showUpgradeStatusModal} adminConsoleMetadata={props.adminConsoleMetadata} />
diff --git a/web/src/features/Dashboard/components/DashboardVersionCard.tsx b/web/src/features/Dashboard/components/DashboardVersionCard.tsx index 5ae7d14fa3..32e9c87294 100644 --- a/web/src/features/Dashboard/components/DashboardVersionCard.tsx +++ b/web/src/features/Dashboard/components/DashboardVersionCard.tsx @@ -67,6 +67,7 @@ type Props = { uploadResuming: boolean; uploadSize: number; viewAirgapUploadError: () => void; + showUpgradeStatusModal: boolean; }; type State = { @@ -201,8 +202,8 @@ const DashboardVersionCard = (props: Props) => { }, [location.search]); useEffect(() => { - if (Utilities.isPendingClusterUpgrade(selectedApp)) { - // if the cluster is upgrading, we don't want to show an error + if (props.showUpgradeStatusModal) { + // if an upgrade is in progress, we don't want to show an error return; } @@ -331,23 +332,6 @@ const DashboardVersionCard = (props: Props) => { }; const getCurrentVersionStatus = (version: Version | null) => { - if ( - Utilities.isPendingClusterUpgrade(selectedApp) && - version?.status === "deployed" - ) { - return ( - - - {selectedApp?.appState !== "ready" - ? "Waiting for app to be ready" - : "Updating cluster"} - - ); - } - if (version?.status === "deployed" || version?.status === "pending") { return ( @@ -679,8 +663,7 @@ const DashboardVersionCard = (props: Props) => {
) : null} - {currentVersion?.status === "deploying" || - Utilities.isPendingClusterUpgrade(selectedApp) ? null : ( + {currentVersion?.status === "deploying" ? null : (