From db807ebe17f24664f2024ef6a8b87117f5e6348e Mon Sep 17 00:00:00 2001 From: Salah Al Saleh Date: Mon, 20 May 2024 23:24:33 +0000 Subject: [PATCH 01/33] KOTS upgrader --- cmd/kots/cli/admin-console-upgrader.go | 33 +++++++++ cmd/kots/cli/admin-console.go | 1 + cmd/kots/cli/version.go | 41 ++++------- pkg/kotsutil/kots.go | 52 ++++++++++++++ pkg/upgrader/server.go | 64 +++++++++++++++++ pkg/upgrader/upgrader.go | 98 ++++++++++++++++++++++++++ 6 files changed, 260 insertions(+), 29 deletions(-) create mode 100644 cmd/kots/cli/admin-console-upgrader.go create mode 100644 pkg/upgrader/server.go create mode 100644 pkg/upgrader/upgrader.go diff --git a/cmd/kots/cli/admin-console-upgrader.go b/cmd/kots/cli/admin-console-upgrader.go new file mode 100644 index 0000000000..d6ffaa1e79 --- /dev/null +++ b/cmd/kots/cli/admin-console-upgrader.go @@ -0,0 +1,33 @@ +package cli + +import ( + "fmt" + + "github.com/replicatedhq/kots/pkg/upgrader" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func AdminConsoleUpgraderCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "upgrader", + Short: "Starts the KOTS Admin Console upgrader service", + Long: `Starts the KOTS Admin Console upgrader service`, + PreRun: func(cmd *cobra.Command, args []string) { + viper.BindPFlags(cmd.Flags()) + }, + RunE: func(cmd *cobra.Command, args []string) error { + params := upgrader.ServerParams{ + Port: fmt.Sprintf("%d", viper.GetInt("port")), + } + if err := upgrader.Serve(params); err != nil { + return err + } + return nil + }, + } + + cmd.Flags().IntP("port", "p", 30001, "local port to listen on") + + return cmd +} diff --git a/cmd/kots/cli/admin-console.go b/cmd/kots/cli/admin-console.go index 386ea65ac3..53163c9fca 100644 --- a/cmd/kots/cli/admin-console.go +++ b/cmd/kots/cli/admin-console.go @@ -107,6 +107,7 @@ func AdminConsoleCmd() *cobra.Command { cmd.AddCommand(AdminCopyPublicImagesCmd()) cmd.AddCommand(GarbageCollectImagesCmd()) cmd.AddCommand(AdminGenerateManifestsCmd()) + cmd.AddCommand(AdminConsoleUpgraderCmd()) return cmd } diff --git a/cmd/kots/cli/version.go b/cmd/kots/cli/version.go index ff3d682063..dbc64305f4 100644 --- a/cmd/kots/cli/version.go +++ b/cmd/kots/cli/version.go @@ -1,11 +1,12 @@ package cli import ( - "encoding/json" "fmt" + "log" + "net/http" - "github.com/pkg/errors" - "github.com/replicatedhq/kots/pkg/buildversion" + "github.com/gorilla/mux" + "github.com/replicatedhq/kots/pkg/handlers" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -25,37 +26,19 @@ func VersionCmd() *cobra.Command { viper.BindPFlags(cmd.Flags()) }, RunE: func(cmd *cobra.Command, args []string) error { - v := viper.GetViper() + r := mux.NewRouter() - output := v.GetString("output") + spa := handlers.SPAHandler{} + r.PathPrefix("/").Handler(spa) - isLatest, latestVer, err := buildversion.IsLatestRelease() - versionOutput := VersionOutput{ - Version: buildversion.Version(), - } - if err == nil && !isLatest { - versionOutput.LatestVersion = latestVer - versionOutput.InstallLatest = "curl https://kots.io/install | bash" + srv := &http.Server{ + Handler: r, + Addr: ":30888", } - if output != "json" && output != "" { - return errors.Errorf("output format %s not supported (allowed formats are: json)", output) - } else if output == "json" { - // marshal JSON - outputJSON, err := json.Marshal(versionOutput) - if err != nil { - return errors.Wrap(err, "error marshaling JSON") - } - fmt.Println(string(outputJSON)) - } else { - // print basic version info - fmt.Printf("Replicated KOTS %s\n", buildversion.Version()) + fmt.Printf("Starting KOTS SPA handler on port %d...\n", 30888) - // check if this is the latest release, and display possible upgrade instructions - if versionOutput.LatestVersion != "" { - fmt.Printf("\nVersion %s is available for kots. To install updates, run\n $ %s\n", versionOutput.LatestVersion, versionOutput.InstallLatest) - } - } + log.Fatal(srv.ListenAndServe()) return nil }, diff --git a/pkg/kotsutil/kots.go b/pkg/kotsutil/kots.go index c7413f60d1..49784c2c71 100644 --- a/pkg/kotsutil/kots.go +++ b/pkg/kotsutil/kots.go @@ -7,6 +7,8 @@ import ( "context" "encoding/base64" "fmt" + "io" + "net/http" "os" "path" "path/filepath" @@ -1517,3 +1519,53 @@ func SaveInstallation(installation *kotsv1beta1.Installation, upstreamDir string } return nil } + +// TODO NOW: download via replicated.app +func DownloadKOTSBinary(version string) (string, error) { + url := fmt.Sprintf("https://github.com/replicatedhq/kots/releases/download/%s/kots_linux_amd64.tar.gz", version) + resp, err := http.Get(url) + if err != nil { + return "", errors.Wrap(err, "failed to get") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", errors.Errorf("unexpected status code %d", resp.StatusCode) + } + + tmpFile, err := os.CreateTemp("", "kots") + if err != nil { + return "", errors.Wrap(err, "failed to create temp file") + } + + 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") + } + break + } + + return "", errors.New("kots binary not found in archive") +} diff --git a/pkg/upgrader/server.go b/pkg/upgrader/server.go new file mode 100644 index 0000000000..11b3979c51 --- /dev/null +++ b/pkg/upgrader/server.go @@ -0,0 +1,64 @@ +package upgrader + +import ( + "fmt" + "log" + "net/http" + + "github.com/gorilla/mux" + "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/buildversion" + "github.com/replicatedhq/kots/pkg/handlers" +) + +type ServerParams struct { + Port string +} + +func Serve(params ServerParams) error { + log.Printf("KOTS version %s\n", buildversion.Version()) + + r := mux.NewRouter() + + r.Use(handlers.CorsMiddleware) + r.Methods("OPTIONS").HandlerFunc(handlers.CORS) + + debugRouter := r.NewRoute().Subrouter() + debugRouter.Use(handlers.DebugLoggingMiddleware) + + loggingRouter := r.NewRoute().Subrouter() + loggingRouter.Use(handlers.LoggingMiddleware) + + handler := &handlers.Handler{} + + // TODO NOW: auth by authSlug token the cli typically uses? + + /********************************************************************** + * KOTS token auth routes + **********************************************************************/ + + handlers.RegisterTokenAuthRoutes(handler, debugRouter, loggingRouter) + + // Prevent API requests that don't match anything in this router from returning UI content + r.PathPrefix("/api").Handler(handlers.StatusNotFoundHandler{}) + + /********************************************************************** + * Static routes + **********************************************************************/ + + spa := handlers.SPAHandler{} + r.PathPrefix("/").Handler(spa) + + srv := &http.Server{ + Handler: r, + Addr: fmt.Sprintf(":%s", params.Port), + } + + fmt.Printf("Starting upgrader on port %s...\n", params.Port) + + if err := srv.ListenAndServe(); err != nil { + return errors.Wrap(err, "failed to listen and serve") + } + + return nil +} diff --git a/pkg/upgrader/upgrader.go b/pkg/upgrader/upgrader.go new file mode 100644 index 0000000000..97fdb79dc5 --- /dev/null +++ b/pkg/upgrader/upgrader.go @@ -0,0 +1,98 @@ +package upgrader + +import ( + _ "embed" + "fmt" + "net/http" + "os" + "os/exec" + "time" + + "github.com/phayes/freeport" + "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/kotsutil" + "github.com/replicatedhq/kots/pkg/logger" +) + +type Upgrader struct { + process *os.Process + port string +} + +// Start will spin up an upgrader service in the background on a random port. +// Caller is responsible for stopping the upgrader. +// The KOTS binary of the specified version will be downloaded and used to start the upgrader. +func (u *Upgrader) Start(kotsVersion string) (finalError error) { + if u.port != "" { + return errors.Errorf("upgrader is already running on port %s", u.port) + } + + defer func() { + if finalError != nil { + u.Stop() + } + }() + + fp, err := freeport.GetFreePort() + if err != nil { + return errors.Wrap(err, "failed to get free port") + } + freePort := fmt.Sprintf("%d", fp) + + kotsBin, err := kotsutil.DownloadKOTSBinary(kotsVersion) + if err != nil { + return errors.Wrapf(err, "failed to download kots binary version %s", kotsVersion) + } + + cmd := exec.Command(kotsBin, "upgrader", "--port", freePort) + if err := cmd.Start(); err != nil { + return errors.Wrap(err, "failed to start") + } + + u.port = freePort + u.process = cmd.Process + + if err := u.WaitForReady(time.Second * 30); err != nil { + return errors.Wrap(err, "failed to wait for upgrader to become ready") + } + + return nil +} + +func (r *Upgrader) Stop() { + if r.process != nil { + if err := r.process.Signal(os.Interrupt); err != nil { + logger.Debugf("Failed to stop upgrader process on port %s", r.port) + } + } + r.port = "" + r.process = nil +} + +func (r *Upgrader) WaitForReady(timeout time.Duration) error { + start := time.Now() + + 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 + } + } + } + + time.Sleep(time.Second) + + if time.Since(start) > timeout { + return errors.Errorf("Timeout waiting for upgrader to become ready on port %s", r.port) + } + } +} + +// This is only used for integration tests +func (r *Upgrader) OverridePort(port string) { + r.port = port +} From d2614a68284c21ac165d7b8fa2b57f1191dd0143 Mon Sep 17 00:00:00 2001 From: Salah Al Saleh Date: Tue, 21 May 2024 02:39:41 +0000 Subject: [PATCH 02/33] updates --- cmd/kots/cli/admin-console-upgrader.go | 2 +- pkg/apiserver/server.go | 5 ++ pkg/upgrader/server.go | 31 ++++++--- pkg/upgrader/upgrader.go | 94 ++++++++++++++++++-------- 4 files changed, 94 insertions(+), 38 deletions(-) diff --git a/cmd/kots/cli/admin-console-upgrader.go b/cmd/kots/cli/admin-console-upgrader.go index d6ffaa1e79..45e1baadb1 100644 --- a/cmd/kots/cli/admin-console-upgrader.go +++ b/cmd/kots/cli/admin-console-upgrader.go @@ -27,7 +27,7 @@ func AdminConsoleUpgraderCmd() *cobra.Command { }, } - cmd.Flags().IntP("port", "p", 30001, "local port to listen on") + cmd.Flags().IntP("port", "p", 30000, "local port to listen on") return cmd } diff --git a/pkg/apiserver/server.go b/pkg/apiserver/server.go index 9323aa32c2..cb2ced38e6 100644 --- a/pkg/apiserver/server.go +++ b/pkg/apiserver/server.go @@ -29,6 +29,7 @@ import ( "github.com/replicatedhq/kots/pkg/store" "github.com/replicatedhq/kots/pkg/supportbundle" "github.com/replicatedhq/kots/pkg/updatechecker" + "github.com/replicatedhq/kots/pkg/upgrader" "github.com/replicatedhq/kots/pkg/util" "golang.org/x/crypto/bcrypt" ) @@ -162,6 +163,10 @@ func Start(params *APIServerParams) { handler := &handlers.Handler{} + // TODO NOW: auth + r.Path("/api/v1/init-upgrader").Methods("POST").HandlerFunc(upgrader.Init) + r.Path("/api/v1/upgrader").Methods("GET", "POST").HandlerFunc(upgrader.Proxy) + /********************************************************************** * Unauthenticated routes **********************************************************************/ diff --git a/pkg/upgrader/server.go b/pkg/upgrader/server.go index 11b3979c51..13f969509c 100644 --- a/pkg/upgrader/server.go +++ b/pkg/upgrader/server.go @@ -4,6 +4,9 @@ import ( "fmt" "log" "net/http" + "net/http/httputil" + "net/url" + "os" "github.com/gorilla/mux" "github.com/pkg/errors" @@ -29,16 +32,10 @@ func Serve(params ServerParams) error { loggingRouter := r.NewRoute().Subrouter() loggingRouter.Use(handlers.LoggingMiddleware) - handler := &handlers.Handler{} + // handler := &handlers.Handler{} // TODO NOW: auth by authSlug token the cli typically uses? - /********************************************************************** - * KOTS token auth routes - **********************************************************************/ - - handlers.RegisterTokenAuthRoutes(handler, debugRouter, loggingRouter) - // Prevent API requests that don't match anything in this router from returning UI content r.PathPrefix("/api").Handler(handlers.StatusNotFoundHandler{}) @@ -46,8 +43,24 @@ func Serve(params ServerParams) error { * Static routes **********************************************************************/ - spa := handlers.SPAHandler{} - r.PathPrefix("/").Handler(spa) + // to avoid confusion, we don't serve this in the dev env... + if os.Getenv("DISABLE_SPA_SERVING") != "1" { + spa := handlers.SPAHandler{} + r.PathPrefix("/").Handler(spa) + } else if os.Getenv("ENABLE_WEB_PROXY") == "1" { // for dev env + u, err := url.Parse("http://kotsadm-web:8080") + if err != nil { + panic(err) + } + 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("/").HandlerFunc(webProxy) + } srv := &http.Server{ Handler: r, diff --git a/pkg/upgrader/upgrader.go b/pkg/upgrader/upgrader.go index 97fdb79dc5..7daa4ffc3f 100644 --- a/pkg/upgrader/upgrader.go +++ b/pkg/upgrader/upgrader.go @@ -4,6 +4,8 @@ import ( _ "embed" "fmt" "net/http" + "net/http/httputil" + "net/url" "os" "os/exec" "time" @@ -14,22 +16,56 @@ import ( "github.com/replicatedhq/kots/pkg/logger" ) -type Upgrader struct { - process *os.Process - port string +var upgraderProcess *os.Process +var upgraderPort string + +// Init will spin up an upgrader service in the background on a random port. +// If an upgrader is already running, it will be stopped and a new one will be started. +// The KOTS binary of the specified version will be used to start the upgrader. +func Init(w http.ResponseWriter, r *http.Request) { + // TODO NOW: get these from the request + kotsVersion := "v1.109.3" + + // stop the upgrader if it's already running. + // don't bail if not able to stop, and start a new one + stop() + + if err := start(kotsVersion); err != nil { + logger.Error(errors.Wrap(err, "failed to start upgrader")) + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) } -// Start will spin up an upgrader service in the background on a random port. -// Caller is responsible for stopping the upgrader. -// The KOTS binary of the specified version will be downloaded and used to start the upgrader. -func (u *Upgrader) Start(kotsVersion string) (finalError error) { - if u.port != "" { - return errors.Errorf("upgrader is already running on port %s", u.port) +// Proxy will proxy the request to the upgrader service. +func Proxy(w http.ResponseWriter, r *http.Request) { + if upgraderPort == "" { + logger.Error(errors.New("upgrader port is not set")) + w.WriteHeader(http.StatusServiceUnavailable) + return + } + + remote, err := url.Parse(fmt.Sprintf("http://localhost:%s", upgraderPort)) + if err != nil { + logger.Error(errors.Wrap(err, "failed to parse upgrader url")) + w.WriteHeader(http.StatusInternalServerError) + return + } + + proxy := httputil.NewSingleHostReverseProxy(remote) + proxy.ServeHTTP(w, r) +} + +func start(kotsVersion string) (finalError error) { + if upgraderPort != "" { + return errors.Errorf("upgrader is already running on port %s", upgraderPort) } defer func() { if finalError != nil { - u.Stop() + stop() } }() @@ -44,36 +80,43 @@ func (u *Upgrader) Start(kotsVersion string) (finalError error) { return errors.Wrapf(err, "failed to download kots binary version %s", kotsVersion) } - cmd := exec.Command(kotsBin, "upgrader", "--port", freePort) + cmd := exec.Command( + kotsBin, + "admin-console", + "upgrader", + "--port", + freePort, + ) + if err := cmd.Start(); err != nil { return errors.Wrap(err, "failed to start") } - u.port = freePort - u.process = cmd.Process + upgraderPort = freePort + upgraderProcess = cmd.Process - if err := u.WaitForReady(time.Second * 30); err != nil { + if err := waitForReady(time.Second * 30); err != nil { return errors.Wrap(err, "failed to wait for upgrader to become ready") } return nil } -func (r *Upgrader) Stop() { - if r.process != nil { - if err := r.process.Signal(os.Interrupt); err != nil { - logger.Debugf("Failed to stop upgrader process on port %s", r.port) +func stop() { + if upgraderProcess != nil { + if err := upgraderProcess.Signal(os.Interrupt); err != nil { + logger.Errorf("Failed to stop upgrader process on port %s", upgraderPort) } } - r.port = "" - r.process = nil + upgraderPort = "" + upgraderProcess = nil } -func (r *Upgrader) WaitForReady(timeout time.Duration) error { +func waitForReady(timeout time.Duration) error { start := time.Now() for { - url := fmt.Sprintf("http://localhost:%s", r.port) + url := fmt.Sprintf("http://localhost:%s", upgraderPort) newRequest, err := http.NewRequest("GET", url, nil) if err == nil { resp, err := http.DefaultClient.Do(newRequest) @@ -87,12 +130,7 @@ func (r *Upgrader) WaitForReady(timeout time.Duration) error { time.Sleep(time.Second) if time.Since(start) > timeout { - return errors.Errorf("Timeout waiting for upgrader to become ready on port %s", r.port) + return errors.Errorf("Timeout waiting for upgrader to become ready on port %s", upgraderPort) } } } - -// This is only used for integration tests -func (r *Upgrader) OverridePort(port string) { - r.port = port -} From 60f11fda65c59ae8d2f7ce7d75d550a4fc2c6624 Mon Sep 17 00:00:00 2001 From: Salah Al Saleh Date: Tue, 21 May 2024 21:57:34 +0000 Subject: [PATCH 03/33] save --- Makefile | 4 +- cmd/kots/cli/admin-console-upgrader.go | 33 --- cmd/kots/cli/admin-console.go | 1 - cmd/kots/cli/root.go | 1 + cmd/kots/cli/upgrader.go | 65 ++++++ pkg/apiserver/server.go | 9 +- pkg/handlers/handlers.go | 10 + pkg/handlers/interface.go | 3 + pkg/handlers/mock/mock.go | 12 ++ pkg/handlers/upgrader.go | 75 +++++++ pkg/kotsutil/kots.go | 7 +- pkg/store/store_interface.go | 7 + pkg/upgrader/handlers/config.go | 274 +++++++++++++++++++++++++ pkg/upgrader/handlers/handlers.go | 43 ++++ pkg/upgrader/handlers/interface.go | 9 + pkg/upgrader/handlers/middleware.go | 68 ++++++ pkg/upgrader/handlers/ping.go | 15 ++ pkg/upgrader/handlers/spa.go | 59 ++++++ pkg/upgrader/handlers/static.go | 13 ++ pkg/upgrader/server.go | 27 +-- pkg/upgrader/types/types.go | 30 +++ pkg/upgrader/upgrader.go | 105 +++++----- web/src/Root.tsx | 69 ++++++- 23 files changed, 822 insertions(+), 117 deletions(-) delete mode 100644 cmd/kots/cli/admin-console-upgrader.go create mode 100644 cmd/kots/cli/upgrader.go create mode 100644 pkg/handlers/upgrader.go create mode 100644 pkg/upgrader/handlers/config.go create mode 100644 pkg/upgrader/handlers/handlers.go create mode 100644 pkg/upgrader/handlers/interface.go create mode 100644 pkg/upgrader/handlers/middleware.go create mode 100644 pkg/upgrader/handlers/ping.go create mode 100644 pkg/upgrader/handlers/spa.go create mode 100644 pkg/upgrader/handlers/static.go create mode 100644 pkg/upgrader/types/types.go diff --git a/Makefile b/Makefile index 3a88f5b0bf..e53f5bb5b6 100644 --- a/Makefile +++ b/Makefile @@ -112,8 +112,10 @@ debug-build: debug: debug-build LOG_LEVEL=$(LOG_LEVEL) dlv --listen=:2345 --headless=true --api-version=2 exec ./bin/kotsadm-debug api +# TODO NOW: make web part of kots cli .PHONY: build-ttl.sh -build-ttl.sh: kots build +# build-ttl.sh: kots build +build-ttl.sh: build source .image.env && ${MAKE} -C web build-kotsadm docker build -f deploy/Dockerfile -t ttl.sh/${CURRENT_USER}/kotsadm:24h . docker push ttl.sh/${CURRENT_USER}/kotsadm:24h diff --git a/cmd/kots/cli/admin-console-upgrader.go b/cmd/kots/cli/admin-console-upgrader.go deleted file mode 100644 index 45e1baadb1..0000000000 --- a/cmd/kots/cli/admin-console-upgrader.go +++ /dev/null @@ -1,33 +0,0 @@ -package cli - -import ( - "fmt" - - "github.com/replicatedhq/kots/pkg/upgrader" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -func AdminConsoleUpgraderCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "upgrader", - Short: "Starts the KOTS Admin Console upgrader service", - Long: `Starts the KOTS Admin Console upgrader service`, - PreRun: func(cmd *cobra.Command, args []string) { - viper.BindPFlags(cmd.Flags()) - }, - RunE: func(cmd *cobra.Command, args []string) error { - params := upgrader.ServerParams{ - Port: fmt.Sprintf("%d", viper.GetInt("port")), - } - if err := upgrader.Serve(params); err != nil { - return err - } - return nil - }, - } - - cmd.Flags().IntP("port", "p", 30000, "local port to listen on") - - return cmd -} diff --git a/cmd/kots/cli/admin-console.go b/cmd/kots/cli/admin-console.go index 53163c9fca..386ea65ac3 100644 --- a/cmd/kots/cli/admin-console.go +++ b/cmd/kots/cli/admin-console.go @@ -107,7 +107,6 @@ func AdminConsoleCmd() *cobra.Command { cmd.AddCommand(AdminCopyPublicImagesCmd()) cmd.AddCommand(GarbageCollectImagesCmd()) cmd.AddCommand(AdminGenerateManifestsCmd()) - cmd.AddCommand(AdminConsoleUpgraderCmd()) return cmd } diff --git a/cmd/kots/cli/root.go b/cmd/kots/cli/root.go index a7b9c9941f..b0cedd76ac 100644 --- a/cmd/kots/cli/root.go +++ b/cmd/kots/cli/root.go @@ -55,6 +55,7 @@ func RootCmd() *cobra.Command { cmd.AddCommand(CompletionCmd()) cmd.AddCommand(DockerRegistryCmd()) cmd.AddCommand(EnableHACmd()) + cmd.AddCommand(StartUpgraderCmd()) viper.BindPFlags(cmd.Flags()) diff --git a/cmd/kots/cli/upgrader.go b/cmd/kots/cli/upgrader.go new file mode 100644 index 0000000000..9b23501884 --- /dev/null +++ b/cmd/kots/cli/upgrader.go @@ -0,0 +1,65 @@ +package cli + +import ( + "fmt" + + "github.com/replicatedhq/kots/pkg/upgrader" + "github.com/replicatedhq/kots/pkg/upgrader/types" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func StartUpgraderCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "start-upgrader", + Short: "Starts the KOTS upgrader service", + Long: `Starts the KOTS upgrader service`, + PreRun: func(cmd *cobra.Command, args []string) { + viper.BindPFlags(cmd.Flags()) + }, + RunE: func(cmd *cobra.Command, args []string) error { + v := viper.GetViper() + + params := types.ServerParams{ + Port: fmt.Sprintf("%d", viper.GetInt("port")), + + AppID: v.GetString("app-id"), + AppSlug: v.GetString("app-slug"), + AppSequence: v.GetInt64("app-sequence"), + AppIsAirgap: v.GetBool("app-is-airgap"), + AppLicense: v.GetString("app-license"), + AppArchive: v.GetString("app-archive"), + + RegistryEndpoint: v.GetString("registry-endpoint"), + RegistryUsername: v.GetString("registry-username"), + RegistryPassword: v.GetString("registry-password"), + RegistryNamespace: v.GetString("registry-namespace"), + RegistryIsReadOnly: v.GetBool("registry-is-readonly"), + } + if err := upgrader.Serve(params); err != nil { + return err + } + + return nil + }, + } + + cmd.Flags().IntP("port", "p", 30000, "local port to listen on") + + // app flags + cmd.Flags().String("app-id", "", "the app id") + cmd.Flags().String("app-slug", "", "the app slug") + cmd.Flags().Int64("app-sequence", -1, "the app sequence") + cmd.Flags().Bool("app-is-airgap", false, "whether the app is airgap") + cmd.Flags().String("app-license", "", "the app license") + cmd.Flags().String("app-archive", "", "path to the app archive") + + // registry flags + cmd.Flags().String("registry-endpoint", "", "the registry endpoint") + cmd.Flags().String("registry-username", "", "the registry username") + cmd.Flags().String("registry-password", "", "the registry password") + cmd.Flags().String("registry-namespace", "", "the registry namespace") + cmd.Flags().Bool("registry-is-readonly", false, "whether the registry is read-only") + + return cmd +} diff --git a/pkg/apiserver/server.go b/pkg/apiserver/server.go index cb2ced38e6..c669118e19 100644 --- a/pkg/apiserver/server.go +++ b/pkg/apiserver/server.go @@ -163,10 +163,6 @@ func Start(params *APIServerParams) { handler := &handlers.Handler{} - // TODO NOW: auth - r.Path("/api/v1/init-upgrader").Methods("POST").HandlerFunc(upgrader.Init) - r.Path("/api/v1/upgrader").Methods("GET", "POST").HandlerFunc(upgrader.Proxy) - /********************************************************************** * Unauthenticated routes **********************************************************************/ @@ -199,6 +195,11 @@ func Start(params *APIServerParams) { * Static routes **********************************************************************/ + // serve the upgrader UI from the upgrader service + // CAUTION: modifying this route WILL break backwards compatibility + r.Path("/upgrader").Methods("GET").HandlerFunc(upgrader.Proxy) + + // TODO NOW: move this to a shared function // to avoid confusion, we don't serve this in the dev env... if os.Getenv("DISABLE_SPA_SERVING") != "1" { spa := handlers.SPAHandler{} diff --git a/pkg/handlers/handlers.go b/pkg/handlers/handlers.go index 8e0a75eacc..adc4c60ed4 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/upgrader" kotsscheme "github.com/replicatedhq/kotskinds/client/kotsclientset/scheme" troubleshootscheme "github.com/replicatedhq/troubleshoot/pkg/client/troubleshootclientset/scheme" yaml "github.com/replicatedhq/yaml/v3" @@ -315,6 +316,15 @@ 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)) + + // Proxy upgrader requests to the upgrader service + // CAUTION: modifying this route WILL break backwards compatibility + r.Name("UpgraderProxy").Path("/api/v1/upgrader").Methods("GET", "POST", "PUT"). + HandlerFunc(middleware.EnforceAccess(policy.AppUpdate, upgrader.Proxy)) + + // Start upgrader + r.Name("StartUpgrader").Path("/api/v1/start-upgrader").Methods("POST"). + HandlerFunc(middleware.EnforceAccess(policy.AppUpdate, handler.StartUpgrader)) } func JSON(w http.ResponseWriter, code int, payload interface{}) { diff --git a/pkg/handlers/interface.go b/pkg/handlers/interface.go index 6e10c76a5a..917f6e3148 100644 --- a/pkg/handlers/interface.go +++ b/pkg/handlers/interface.go @@ -161,4 +161,7 @@ type KOTSHandler interface { // Password change ChangePassword(w http.ResponseWriter, r *http.Request) + + // Upgrader + StartUpgrader(w http.ResponseWriter, r *http.Request) } diff --git a/pkg/handlers/mock/mock.go b/pkg/handlers/mock/mock.go index 0eb87eb88c..8fd9f05e0b 100644 --- a/pkg/handlers/mock/mock.go +++ b/pkg/handlers/mock/mock.go @@ -1390,6 +1390,18 @@ func (mr *MockKOTSHandlerMockRecorder) StartPreflightChecks(w, r interface{}) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartPreflightChecks", reflect.TypeOf((*MockKOTSHandler)(nil).StartPreflightChecks), w, r) } +// StartUpgrader mocks base method. +func (m *MockKOTSHandler) StartUpgrader(w http.ResponseWriter, r *http.Request) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "StartUpgrader", w, r) +} + +// StartUpgrader indicates an expected call of StartUpgrader. +func (mr *MockKOTSHandlerMockRecorder) StartUpgrader(w, r interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StartUpgrader", reflect.TypeOf((*MockKOTSHandler)(nil).StartUpgrader), w, r) +} + // SyncLicense mocks base method. func (m *MockKOTSHandler) SyncLicense(w http.ResponseWriter, r *http.Request) { m.ctrl.T.Helper() diff --git a/pkg/handlers/upgrader.go b/pkg/handlers/upgrader.go new file mode 100644 index 0000000000..bc54bd045b --- /dev/null +++ b/pkg/handlers/upgrader.go @@ -0,0 +1,75 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "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/upgrader" + upgradertypes "github.com/replicatedhq/kots/pkg/upgrader/types" +) + +type StartUpgraderRequest struct { + KOTSVersion string `json:"kotsVersion"` +} + +type StartUpgraderResponse struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` +} + +func (h *Handler) StartUpgrader(w http.ResponseWriter, r *http.Request) { + response := StartUpgraderResponse{ + Success: false, + } + + request := StartUpgraderRequest{} + 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 + } + + registrySettings, err := store.GetStore().GetRegistryDetailsForApp(foundApp.ID) + if err != nil { + response.Error = "failed to get registry details for app" + logger.Error(errors.Wrap(err, response.Error)) + JSON(w, http.StatusInternalServerError, response) + return + } + + // TODO NOW: get cursor from request + // TODO NOW: download archive from replicated.app in online mode + // TODO NOW: extract archive in airgap mode + + err = upgrader.Start(upgradertypes.StartOptions{ + KOTSVersion: request.KOTSVersion, + App: foundApp, + AppArchive: "", + RegistrySettings: registrySettings, + }) + if err != nil { + response.Error = "failed to start upgrader" + 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/kotsutil/kots.go b/pkg/kotsutil/kots.go index 49784c2c71..81a545fee7 100644 --- a/pkg/kotsutil/kots.go +++ b/pkg/kotsutil/kots.go @@ -1537,6 +1537,7 @@ func DownloadKOTSBinary(version string) (string, error) { if err != nil { return "", errors.Wrap(err, "failed to create temp file") } + defer tmpFile.Close() gzipReader, err := gzip.NewReader(resp.Body) if err != nil { @@ -1564,7 +1565,11 @@ func DownloadKOTSBinary(version string) (string, error) { if _, err := io.Copy(tmpFile, tarReader); err != nil { return "", errors.Wrap(err, "failed to copy kots binary") } - break + 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/store/store_interface.go b/pkg/store/store_interface.go index 05f1ee395e..22d45087bb 100644 --- a/pkg/store/store_interface.go +++ b/pkg/store/store_interface.go @@ -23,6 +23,13 @@ import ( usertypes "github.com/replicatedhq/kots/pkg/user/types" kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" troubleshootredact "github.com/replicatedhq/troubleshoot/pkg/redact" + + // this is to prevent the "upgrader" package from importing the store. + // any store related information should be passed to the upgrader as: + // - start-upgrader cli command flags + // - files in the filesystem via the shared pod volumes + // - environment variables + _ "github.com/replicatedhq/kots/pkg/upgrader" ) type Store interface { diff --git a/pkg/upgrader/handlers/config.go b/pkg/upgrader/handlers/config.go new file mode 100644 index 0000000000..68aff7d418 --- /dev/null +++ b/pkg/upgrader/handlers/config.go @@ -0,0 +1,274 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "path/filepath" + "strconv" + + "github.com/gorilla/mux" + "github.com/pkg/errors" + "github.com/replicatedhq/kots/pkg/config" + kotsconfig "github.com/replicatedhq/kots/pkg/config" + 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/template" + "github.com/replicatedhq/kots/pkg/util" + kotsv1beta1 "github.com/replicatedhq/kotskinds/apis/kots/v1beta1" + "github.com/replicatedhq/kotskinds/multitype" +) + +type CurrentAppConfigResponse struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` + ConfigGroups []kotsv1beta1.ConfigGroup `json:"configGroups"` + ValidationErrors []configtypes.ConfigGroupValidationError `json:"validationErrors,omitempty"` +} + +type LiveAppConfigRequest struct { + Sequence int64 `json:"sequence"` + ConfigGroups []kotsv1beta1.ConfigGroup `json:"configGroups"` +} + +type LiveAppConfigResponse struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` + ConfigGroups []kotsv1beta1.ConfigGroup `json:"configGroups"` + ValidationErrors []configtypes.ConfigGroupValidationError `json:"validationErrors,omitempty"` +} + +func (h *Handler) CurrentAppConfig(w http.ResponseWriter, r *http.Request) { + currentAppConfigResponse := CurrentAppConfigResponse{ + Success: false, + } + + params := GetContextParams(r) + appSlug := mux.Vars(r)["appSlug"] + + if params.AppSlug != appSlug { + currentAppConfigResponse.Error = "app slug does not match" + JSON(w, http.StatusForbidden, currentAppConfigResponse) + return + } + + sequence, err := strconv.ParseInt(mux.Vars(r)["sequence"], 10, 64) + if err != nil { + logger.Error(err) + currentAppConfigResponse.Error = "failed to parse app sequence" + JSON(w, http.StatusInternalServerError, currentAppConfigResponse) + return + } + + if params.AppSequence != sequence { + currentAppConfigResponse.Error = "app sequence does not match" + JSON(w, http.StatusForbidden, currentAppConfigResponse) + return + } + + appLicense, err := kotsutil.LoadLicenseFromBytes([]byte(params.AppLicense)) + if err != nil { + currentAppConfigResponse.Error = "failed to load license from bytes" + logger.Error(errors.Wrap(err, currentAppConfigResponse.Error)) + JSON(w, http.StatusInternalServerError, currentAppConfigResponse) + return + } + + kotsKinds, err := kotsutil.LoadKotsKinds(params.AppArchive) + if err != nil { + currentAppConfigResponse.Error = "failed to load kots kinds from path" + logger.Error(errors.Wrap(err, currentAppConfigResponse.Error)) + JSON(w, http.StatusInternalServerError, currentAppConfigResponse) + 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 { + currentAppConfigResponse.Error = "failed to find non-rendered config" + logger.Error(errors.Wrap(err, currentAppConfigResponse.Error)) + JSON(w, http.StatusInternalServerError, currentAppConfigResponse) + return + } + + localRegistry := 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 + } + } + + sequence = sequence + 1 + versionInfo := template.VersionInfoFromInstallationSpec(sequence, params.AppIsAirgap, kotsKinds.Installation.Spec) // sequence +1 because the sequence will be incremented on save (and we want the preview to be accurate) + appInfo := template.ApplicationInfo{Slug: params.AppSlug} + renderedConfig, err := kotsconfig.TemplateConfigObjects(nonRenderedConfig, configValues, appLicense, &kotsKinds.KotsApplication, localRegistry, &versionInfo, &appInfo, kotsKinds.IdentityConfig, util.PodNamespace, false) + if err != nil { + logger.Error(err) + currentAppConfigResponse.Error = "failed to render templates" + JSON(w, http.StatusInternalServerError, currentAppConfigResponse) + return + } + + currentAppConfigResponse.ConfigGroups = []kotsv1beta1.ConfigGroup{} + if renderedConfig != nil { + currentAppConfigResponse.ConfigGroups = renderedConfig.Spec.Groups + } + + currentAppConfigResponse.Success = true + JSON(w, http.StatusOK, currentAppConfigResponse) +} + +func (h *Handler) LiveAppConfig(w http.ResponseWriter, r *http.Request) { + liveAppConfigResponse := LiveAppConfigResponse{ + Success: false, + } + + params := GetContextParams(r) + appSlug := mux.Vars(r)["appSlug"] + + if params.AppSlug != appSlug { + liveAppConfigResponse.Error = "app slug does not match" + JSON(w, http.StatusForbidden, liveAppConfigResponse) + return + } + + appLicense, err := kotsutil.LoadLicenseFromBytes([]byte(params.AppLicense)) + if err != nil { + liveAppConfigResponse.Error = "failed to load license from bytes" + logger.Error(errors.Wrap(err, liveAppConfigResponse.Error)) + JSON(w, http.StatusInternalServerError, liveAppConfigResponse) + return + } + + liveAppConfigRequest := LiveAppConfigRequest{} + if err := json.NewDecoder(r.Body).Decode(&liveAppConfigRequest); err != nil { + logger.Error(err) + liveAppConfigResponse.Error = "failed to decode request body" + JSON(w, http.StatusBadRequest, liveAppConfigResponse) + return + } + + kotsKinds, err := kotsutil.LoadKotsKinds(params.AppArchive) + if err != nil { + liveAppConfigResponse.Error = "failed to load kots kinds from path" + logger.Error(errors.Wrap(err, liveAppConfigResponse.Error)) + JSON(w, http.StatusInternalServerError, liveAppConfigResponse) + 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 { + liveAppConfigResponse.Error = "failed to find non-rendered config" + logger.Error(errors.Wrap(err, liveAppConfigResponse.Error)) + JSON(w, http.StatusInternalServerError, liveAppConfigResponse) + return + } + + localRegistry := 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) + sequence := liveAppConfigRequest.Sequence + 1 + configValues := configValuesFromConfigGroups(liveAppConfigRequest.ConfigGroups) + versionInfo := template.VersionInfoFromInstallationSpec(sequence, params.AppIsAirgap, kotsKinds.Installation.Spec) + appInfo := template.ApplicationInfo{Slug: params.AppSlug} + + renderedConfig, err := kotsconfig.TemplateConfigObjects(nonRenderedConfig, configValues, appLicense, &kotsKinds.KotsApplication, localRegistry, &versionInfo, &appInfo, kotsKinds.IdentityConfig, util.PodNamespace, false) + if err != nil { + liveAppConfigResponse.Error = "failed to render templates" + logger.Error(errors.Wrap(err, liveAppConfigResponse.Error)) + JSON(w, http.StatusInternalServerError, liveAppConfigResponse) + return + } + + liveAppConfigResponse.ConfigGroups = []kotsv1beta1.ConfigGroup{} + if renderedConfig != nil { + validationErrors, err := configvalidation.ValidateConfigSpec(renderedConfig.Spec) + if err != nil { + liveAppConfigResponse.Error = "failed to validate config spec" + logger.Error(errors.Wrap(err, liveAppConfigResponse.Error)) + JSON(w, http.StatusInternalServerError, liveAppConfigResponse) + return + } + + liveAppConfigResponse.ConfigGroups = renderedConfig.Spec.Groups + if len(validationErrors) > 0 { + liveAppConfigResponse.ValidationErrors = validationErrors + logger.Warnf("Validation errors found for config spec: %v", validationErrors) + } + } + + liveAppConfigResponse.Success = true + JSON(w, http.StatusOK, liveAppConfigResponse) +} + +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 +} diff --git a/pkg/upgrader/handlers/handlers.go b/pkg/upgrader/handlers/handlers.go new file mode 100644 index 0000000000..26001a9fb9 --- /dev/null +++ b/pkg/upgrader/handlers/handlers.go @@ -0,0 +1,43 @@ +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 _ UpgraderHandler = (*Handler)(nil) + +type Handler struct { +} + +func init() { + kotsscheme.AddToScheme(scheme.Scheme) + troubleshootscheme.AddToScheme(scheme.Scheme) + veleroscheme.AddToScheme(scheme.Scheme) +} + +func RegisterRoutes(r *mux.Router, handler UpgraderHandler) { + r.Use(LoggingMiddleware) + + r.Path("/api/v1/upgrader/app/{appSlug}/liveconfig").Methods("POST").HandlerFunc(handler.LiveAppConfig) +} + +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/upgrader/handlers/interface.go b/pkg/upgrader/handlers/interface.go new file mode 100644 index 0000000000..b895d79b1a --- /dev/null +++ b/pkg/upgrader/handlers/interface.go @@ -0,0 +1,9 @@ +package handlers + +import "net/http" + +type UpgraderHandler interface { + Ping(w http.ResponseWriter, r *http.Request) + + LiveAppConfig(w http.ResponseWriter, r *http.Request) +} diff --git a/pkg/upgrader/handlers/middleware.go b/pkg/upgrader/handlers/middleware.go new file mode 100644 index 0000000000..218e4263c0 --- /dev/null +++ b/pkg/upgrader/handlers/middleware.go @@ -0,0 +1,68 @@ +package handlers + +import ( + "context" + "net/http" + "os" + "time" + + "github.com/gorilla/mux" + "github.com/replicatedhq/kots/pkg/logger" + "github.com/replicatedhq/kots/pkg/upgrader/types" +) + +type paramsKey struct{} + +func SetContextParams(r *http.Request, params types.ServerParams) *http.Request { + return r.WithContext(context.WithValue(r.Context(), paramsKey{}, params)) +} + +func GetContextParams(r *http.Request) types.ServerParams { + val := r.Context().Value(paramsKey{}) + sess, _ := val.(types.ServerParams) + return sess +} + +func ParamsMiddleware(params types.ServerParams) 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, + ) + }) +} diff --git a/pkg/upgrader/handlers/ping.go b/pkg/upgrader/handlers/ping.go new file mode 100644 index 0000000000..f2c193804c --- /dev/null +++ b/pkg/upgrader/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/upgrader/handlers/spa.go b/pkg/upgrader/handlers/spa.go new file mode 100644 index 0000000000..cfa68de9a7 --- /dev/null +++ b/pkg/upgrader/handlers/spa.go @@ -0,0 +1,59 @@ +package handlers + +import ( + "context" + "io/fs" + "net/http" + "os" + "path/filepath" + + "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) { + // get the absolute path to prevent directory traversal + path, err := filepath.Abs(r.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 + + // check whether a file exists at the given path + fsys, err := fs.Sub(web.Content, "dist") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + _, err = fs.Stat(fsys, filepath.Join(".", path)) // because ... fs.Sub seems to require this + + rr := r /// because the docs say to not modify request, and we might need to, so lets clone + if os.IsNotExist(err) { + rr = r.Clone(context.Background()) + // file does not exist, serve index.html + rr.URL.Path = "/" + } 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 + http.FileServer(http.FS(fsys)).ServeHTTP(w, rr) +} diff --git a/pkg/upgrader/handlers/static.go b/pkg/upgrader/handlers/static.go new file mode 100644 index 0000000000..8a908e463a --- /dev/null +++ b/pkg/upgrader/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/upgrader/server.go b/pkg/upgrader/server.go index 13f969509c..dd7ff9d027 100644 --- a/pkg/upgrader/server.go +++ b/pkg/upgrader/server.go @@ -11,30 +11,21 @@ import ( "github.com/gorilla/mux" "github.com/pkg/errors" "github.com/replicatedhq/kots/pkg/buildversion" - "github.com/replicatedhq/kots/pkg/handlers" + "github.com/replicatedhq/kots/pkg/upgrader/handlers" + "github.com/replicatedhq/kots/pkg/upgrader/types" ) -type ServerParams struct { - Port string -} - -func Serve(params ServerParams) error { - log.Printf("KOTS version %s\n", buildversion.Version()) +func Serve(params types.ServerParams) error { + log.Printf("KOTS Upgrader version %s\n", buildversion.Version()) r := mux.NewRouter() + r.Use(handlers.ParamsMiddleware(params)) - r.Use(handlers.CorsMiddleware) - r.Methods("OPTIONS").HandlerFunc(handlers.CORS) - - debugRouter := r.NewRoute().Subrouter() - debugRouter.Use(handlers.DebugLoggingMiddleware) - - loggingRouter := r.NewRoute().Subrouter() - loggingRouter.Use(handlers.LoggingMiddleware) + handler := &handlers.Handler{} - // handler := &handlers.Handler{} + r.Path("/api/v1/upgrader/ping").Methods("GET").HandlerFunc(handler.Ping) - // TODO NOW: auth by authSlug token the cli typically uses? + handlers.RegisterRoutes(r, handler) // Prevent API requests that don't match anything in this router from returning UI content r.PathPrefix("/api").Handler(handlers.StatusNotFoundHandler{}) @@ -67,7 +58,7 @@ func Serve(params ServerParams) error { Addr: fmt.Sprintf(":%s", params.Port), } - fmt.Printf("Starting upgrader on port %s...\n", params.Port) + fmt.Printf("Starting KOTS Upgrader on port %s...\n", params.Port) if err := srv.ListenAndServe(); err != nil { return errors.Wrap(err, "failed to listen and serve") diff --git a/pkg/upgrader/types/types.go b/pkg/upgrader/types/types.go new file mode 100644 index 0000000000..9f83f1d86d --- /dev/null +++ b/pkg/upgrader/types/types.go @@ -0,0 +1,30 @@ +package types + +import ( + apptypes "github.com/replicatedhq/kots/pkg/app/types" + registrytypes "github.com/replicatedhq/kots/pkg/registry/types" +) + +type StartOptions struct { + KOTSVersion string + App *apptypes.App + AppArchive string + RegistrySettings registrytypes.RegistrySettings +} + +type ServerParams struct { + Port string + + AppID string + AppSlug string + AppSequence int64 + AppIsAirgap bool + AppLicense string + AppArchive string + + RegistryEndpoint string + RegistryUsername string + RegistryPassword string + RegistryNamespace string + RegistryIsReadOnly bool +} diff --git a/pkg/upgrader/upgrader.go b/pkg/upgrader/upgrader.go index 7daa4ffc3f..4df41d2095 100644 --- a/pkg/upgrader/upgrader.go +++ b/pkg/upgrader/upgrader.go @@ -12,82 +12,62 @@ import ( "github.com/phayes/freeport" "github.com/pkg/errors" - "github.com/replicatedhq/kots/pkg/kotsutil" "github.com/replicatedhq/kots/pkg/logger" + "github.com/replicatedhq/kots/pkg/upgrader/types" ) var upgraderProcess *os.Process var upgraderPort string -// Init will spin up an upgrader service in the background on a random port. +// Start will spin up an upgrader service in the background on a random port. // If an upgrader is already running, it will be stopped and a new one will be started. // The KOTS binary of the specified version will be used to start the upgrader. -func Init(w http.ResponseWriter, r *http.Request) { - // TODO NOW: get these from the request - kotsVersion := "v1.109.3" - - // stop the upgrader if it's already running. - // don't bail if not able to stop, and start a new one - stop() - - if err := start(kotsVersion); err != nil { - logger.Error(errors.Wrap(err, "failed to start upgrader")) - w.WriteHeader(http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) -} - -// Proxy will proxy the request to the upgrader service. -func Proxy(w http.ResponseWriter, r *http.Request) { - if upgraderPort == "" { - logger.Error(errors.New("upgrader port is not set")) - w.WriteHeader(http.StatusServiceUnavailable) - return - } - - remote, err := url.Parse(fmt.Sprintf("http://localhost:%s", upgraderPort)) - if err != nil { - logger.Error(errors.Wrap(err, "failed to parse upgrader url")) - w.WriteHeader(http.StatusInternalServerError) - return - } - - proxy := httputil.NewSingleHostReverseProxy(remote) - proxy.ServeHTTP(w, r) -} - -func start(kotsVersion string) (finalError error) { - if upgraderPort != "" { - return errors.Errorf("upgrader is already running on port %s", upgraderPort) - } - +func Start(opts types.StartOptions) (finalError error) { defer func() { if finalError != nil { stop() } }() + // stop the upgrader if it's already running. + // don't bail if not able to stop, and start a new one + stop() + fp, err := freeport.GetFreePort() if err != nil { return errors.Wrap(err, "failed to get free port") } freePort := fmt.Sprintf("%d", fp) - kotsBin, err := kotsutil.DownloadKOTSBinary(kotsVersion) - if err != nil { - return errors.Wrapf(err, "failed to download kots binary version %s", kotsVersion) - } + // TODO NOW: uncomment this + // kotsBin, err := kotsutil.DownloadKOTSBinary(request.KOTSVersion) + // if err != nil { + // return errors.Wrapf(err, "failed to download kots binary version %s", kotsVersion) + // } cmd := exec.Command( - kotsBin, - "admin-console", - "upgrader", - "--port", - freePort, + // kotsBin, + "/kots", + "start-upgrader", + "--port", freePort, + + "--app-id", opts.App.ID, + "--app-slug", opts.App.Slug, + "--app-sequence", fmt.Sprintf("%d", opts.App.CurrentSequence), + "--app-is-airgap", fmt.Sprintf("%t", opts.App.IsAirgap), + "--app-license", opts.App.License, + "--app-archive", opts.AppArchive, + + "--registry-endpoint", opts.RegistrySettings.Hostname, + "--registry-username", opts.RegistrySettings.Username, + "--registry-password", opts.RegistrySettings.Password, + "--registry-namespace", opts.RegistrySettings.Namespace, + "--registry-is-readonly", fmt.Sprintf("%t", opts.RegistrySettings.IsReadOnly), ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { return errors.Wrap(err, "failed to start") } @@ -102,6 +82,25 @@ func start(kotsVersion string) (finalError error) { return nil } +// Proxy will proxy the request to the upgrader service. +func Proxy(w http.ResponseWriter, r *http.Request) { + if upgraderPort == "" { + logger.Error(errors.New("upgrader port is not set")) + w.WriteHeader(http.StatusServiceUnavailable) + return + } + + remote, err := url.Parse(fmt.Sprintf("http://localhost:%s", upgraderPort)) + if err != nil { + logger.Error(errors.Wrap(err, "failed to parse upgrader url")) + w.WriteHeader(http.StatusInternalServerError) + return + } + + proxy := httputil.NewSingleHostReverseProxy(remote) + proxy.ServeHTTP(w, r) +} + func stop() { if upgraderProcess != nil { if err := upgraderProcess.Signal(os.Interrupt); err != nil { @@ -116,7 +115,7 @@ func waitForReady(timeout time.Duration) error { start := time.Now() for { - url := fmt.Sprintf("http://localhost:%s", upgraderPort) + url := fmt.Sprintf("http://localhost:%s/ping", upgraderPort) newRequest, err := http.NewRequest("GET", url, nil) if err == nil { resp, err := http.DefaultClient.Do(newRequest) diff --git a/web/src/Root.tsx b/web/src/Root.tsx index 0a7d547c89..cc934d701f 100644 --- a/web/src/Root.tsx +++ b/web/src/Root.tsx @@ -103,6 +103,7 @@ type State = { snapshotInProgressApps: string[]; isEmbeddedClusterWaitingForNodes: boolean; themeState: ThemeState; + shouldShowUpgraderModal: boolean; }; let interval: ReturnType | undefined; @@ -135,6 +136,7 @@ const Root = () => { navbarLogo: null, }, app: null, + shouldShowUpgraderModal: false, } ); @@ -328,15 +330,39 @@ const Root = () => { const onRootMounted = () => { fetchKotsAppMetadata(); - if (Utilities.isLoggedIn()) { - ping(); - getAppsList().then((appsList) => { - if (appsList?.length > 0 && window.location.pathname === "/apps") { - const { slug } = appsList[0]; - history.replace(`/app/${slug}`); + + if (window.location.pathname !== "/upgrader") { + fetch(`${process.env.API_ENDPOINT}/init-upgrader`, { + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + credentials: "include", + method: "POST", + }).then(async (res) => { + if (res.ok) { + setState({ + shouldShowUpgraderModal: true, + }); + return; } + const text = await res.text(); + console.log("failed to init upgrader", text); + }) + .catch((err) => { + console.log(err); }); + if (Utilities.isLoggedIn()) { + ping(); + getAppsList().then((appsList) => { + if (appsList?.length > 0 && window.location.pathname === "/apps") { + const { slug } = appsList[0]; + history.replace(`/app/${slug}`); + } + }); + } } + }; useEffect(() => { @@ -461,6 +487,26 @@ const Root = () => { /> } />{" "} } /> + {/* +

+ Hello from KOTS Upgrader! +

+ + } + /> + +

+ Hello from test in KOTS Upgrader! +

+ + } + /> */} { /> )} + +